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::{self, Write as _};
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 map = crate::format::kvn::FieldMap::parse(text);
227    Omm::from_field_map(&map)
228}
229
230/// Encode an [`Omm`] as a CCSDS OMM KVN message.
231///
232/// Numeric values use their shortest round-tripping decimal form, so parsing the
233/// output reproduces the same `f64`s. The epoch is emitted to microseconds.
234pub fn encode_kvn(omm: &Omm) -> String {
235    let mut out = String::new();
236    let header = crate::astro::ndm::NdmHeader {
237        vers: omm.ccsds_omm_vers.clone(),
238        creation_date: omm.creation_date.clone(),
239        originator: omm.originator.clone(),
240    };
241    for line in header.write_kvn("CCSDS_OMM_VERS") {
242        out.push_str(&line);
243        out.push('\n');
244    }
245
246    let mut kv = |key: &str, value: &str| {
247        out.push_str(key);
248        out.push_str(" = ");
249        out.push_str(value);
250        out.push('\n');
251    };
252
253    kv("OBJECT_NAME", omm.object_name.as_deref().unwrap_or(""));
254    kv("OBJECT_ID", omm.object_id.as_deref().unwrap_or(""));
255    kv("CENTER_NAME", omm.center_name.as_deref().unwrap_or(""));
256    kv("REF_FRAME", omm.ref_frame.as_deref().unwrap_or(""));
257    kv("TIME_SYSTEM", omm.time_system.as_deref().unwrap_or(""));
258    kv(
259        "MEAN_ELEMENT_THEORY",
260        omm.mean_element_theory.as_deref().unwrap_or(""),
261    );
262    kv("EPOCH", &omm.epoch.to_iso8601());
263    kv("MEAN_MOTION", &fmt_num(omm.mean_motion));
264    kv("ECCENTRICITY", &fmt_num(omm.eccentricity));
265    kv("INCLINATION", &fmt_num(omm.inclination_deg));
266    kv("RA_OF_ASC_NODE", &fmt_num(omm.ra_of_asc_node_deg));
267    kv("ARG_OF_PERICENTER", &fmt_num(omm.arg_of_pericenter_deg));
268    kv("MEAN_ANOMALY", &fmt_num(omm.mean_anomaly_deg));
269    kv("EPHEMERIS_TYPE", &omm.ephemeris_type.to_string());
270    kv("CLASSIFICATION_TYPE", &omm.classification_type);
271    kv("NORAD_CAT_ID", &omm.norad_cat_id.to_string());
272    kv("ELEMENT_SET_NO", &omm.element_set_no.to_string());
273    kv("REV_AT_EPOCH", &omm.rev_at_epoch.to_string());
274    kv("BSTAR", &fmt_num(omm.bstar));
275    kv("MEAN_MOTION_DOT", &fmt_num(omm.mean_motion_dot));
276    kv("MEAN_MOTION_DDOT", &fmt_num(omm.mean_motion_ddot));
277    out
278}
279
280// ── XML ──────────────────────────────────────────────────────────────
281
282/// Parse a CCSDS OMM in XML encoding into an [`Omm`].
283///
284/// Uses the `roxmltree` DOM reader (which handles the `<?xml?>` declaration,
285/// namespaces, comments, and entity decoding) and then reads the known CCSDS
286/// leaf elements by name into the shared `(key, value)` field set, exactly the
287/// representation the KVN tokenizer produces, so both encodings flow through the
288/// single field mapping. The format-set version is taken from the `version`
289/// attribute on `<omm>`.
290pub fn parse_xml(text: &str) -> Result<Omm, OmmError> {
291    let doc = Document::parse(text).map_err(|e| OmmError::Field(format!("malformed XML: {e}")))?;
292    let mut fields: Vec<(String, String)> = Vec::new();
293
294    if let Some(omm_el) = doc
295        .descendants()
296        .find(|n| n.is_element() && n.tag_name().name() == "omm")
297    {
298        if let Some(version) = omm_el.attribute("version") {
299            fields.push(("CCSDS_OMM_VERS".to_string(), version.trim().to_string()));
300        }
301    }
302
303    for node in doc.descendants().filter(roxmltree::Node::is_element) {
304        let name = node.tag_name().name();
305        if FIELD_TAGS.contains(&name) {
306            let value = node.text().unwrap_or("").trim().to_string();
307            fields.push((name.to_string(), value));
308        }
309    }
310
311    let map = crate::format::kvn::FieldMap::from_pairs(fields);
312    Omm::from_field_map(&map)
313}
314
315/// Encode an [`Omm`] as a CCSDS OMM XML message, following the CelesTrak/`ndm`
316/// document layout. Numeric values use their shortest round-tripping form and
317/// text values are XML-escaped, so parsing the output reproduces the same [`Omm`].
318pub fn encode_xml(omm: &Omm) -> String {
319    fn elem(name: &str, value: &str) -> String {
320        format!("<{name}>{value}</{name}>")
321    }
322    let opt = |value: &Option<String>| xml::escape_opt(value);
323
324    let mut s = String::new();
325    s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
326    s.push_str("<ndm>\n");
327    let _ = writeln!(
328        s,
329        "<omm id=\"CCSDS_OMM_VERS\" version=\"{}\">",
330        xml::escape(&omm.ccsds_omm_vers)
331    );
332
333    s.push_str("<header>");
334    s.push_str(&elem("CREATION_DATE", &opt(&omm.creation_date)));
335    s.push_str(&elem("ORIGINATOR", &opt(&omm.originator)));
336    s.push_str("</header>\n");
337
338    s.push_str("<body><segment>\n<metadata>");
339    s.push_str(&elem("OBJECT_NAME", &opt(&omm.object_name)));
340    s.push_str(&elem("OBJECT_ID", &opt(&omm.object_id)));
341    s.push_str(&elem("CENTER_NAME", &opt(&omm.center_name)));
342    s.push_str(&elem("REF_FRAME", &opt(&omm.ref_frame)));
343    s.push_str(&elem("TIME_SYSTEM", &opt(&omm.time_system)));
344    s.push_str(&elem("MEAN_ELEMENT_THEORY", &opt(&omm.mean_element_theory)));
345    s.push_str("</metadata>\n<data>\n<meanElements>");
346
347    s.push_str(&elem("EPOCH", &omm.epoch.to_iso8601()));
348    s.push_str(&elem("MEAN_MOTION", &fmt_num(omm.mean_motion)));
349    s.push_str(&elem("ECCENTRICITY", &fmt_num(omm.eccentricity)));
350    s.push_str(&elem("INCLINATION", &fmt_num(omm.inclination_deg)));
351    s.push_str(&elem("RA_OF_ASC_NODE", &fmt_num(omm.ra_of_asc_node_deg)));
352    s.push_str(&elem(
353        "ARG_OF_PERICENTER",
354        &fmt_num(omm.arg_of_pericenter_deg),
355    ));
356    s.push_str(&elem("MEAN_ANOMALY", &fmt_num(omm.mean_anomaly_deg)));
357    s.push_str("</meanElements>\n<tleParameters>");
358
359    s.push_str(&elem("EPHEMERIS_TYPE", &omm.ephemeris_type.to_string()));
360    s.push_str(&elem(
361        "CLASSIFICATION_TYPE",
362        &xml::escape(&omm.classification_type),
363    ));
364    s.push_str(&elem("NORAD_CAT_ID", &omm.norad_cat_id.to_string()));
365    s.push_str(&elem("ELEMENT_SET_NO", &omm.element_set_no.to_string()));
366    s.push_str(&elem("REV_AT_EPOCH", &omm.rev_at_epoch.to_string()));
367    s.push_str(&elem("BSTAR", &fmt_num(omm.bstar)));
368    s.push_str(&elem("MEAN_MOTION_DOT", &fmt_num(omm.mean_motion_dot)));
369    s.push_str(&elem("MEAN_MOTION_DDOT", &fmt_num(omm.mean_motion_ddot)));
370    s.push_str("</tleParameters>\n</data>\n</segment></body>\n</omm>\n</ndm>\n");
371    s
372}
373
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.
383pub fn parse_json(text: &str) -> Result<Omm, OmmError> {
384    use serde_json::Value;
385
386    let value: Value =
387        serde_json::from_str(text).map_err(|e| OmmError::Field(format!("malformed JSON: {e}")))?;
388    let object = match &value {
389        Value::Array(items) => items
390            .first()
391            .ok_or_else(|| OmmError::Field("empty JSON array".to_string()))?,
392        Value::Object(_) => &value,
393        _ => {
394            return Err(OmmError::Field(
395                "expected a JSON object or array".to_string(),
396            ))
397        }
398    };
399    omm_from_json_value(object)
400}
401
402/// Map a single JSON OMM object onto the shared `(key, value)` field set and
403/// parse it. Used by both [`parse_json`] and [`parse_json_array`].
404fn omm_from_json_value(object: &serde_json::Value) -> Result<Omm, OmmError> {
405    let map = object
406        .as_object()
407        .ok_or_else(|| OmmError::Field("expected a JSON object".to_string()))?;
408    let fields: Vec<(String, String)> = map
409        .iter()
410        .map(|(key, value)| (key.clone(), json_scalar_to_string(value)))
411        .collect();
412    let map = crate::format::kvn::FieldMap::from_pairs(fields);
413    Omm::from_field_map(&map)
414}
415
416/// The result of parsing a CelesTrak OMM JSON array: every OMM that parsed,
417/// plus a count of array elements that were skipped.
418#[derive(Debug, Clone, PartialEq)]
419pub struct OmmArray {
420    /// The successfully parsed OMMs, in array order.
421    pub omms: Vec<Omm>,
422    /// How many array elements were skipped because they were not a parseable
423    /// OMM object (a non-object element, or an object that failed field
424    /// validation). Mirrors [`crate::astro::sgp4::TleFile::skipped`]: lets
425    /// callers tell an empty array (`omms` empty, `skipped == 0`) apart from one
426    /// whose every element was malformed (`skipped > 0`) without aborting the
427    /// whole parse on one bad entry. No fabricated OMM is emitted in their place.
428    pub skipped: usize,
429}
430
431/// Parse a CelesTrak OMM JSON array into every contained [`Omm`].
432///
433/// CelesTrak GP queries return a JSON array of OMM objects; this reads all of
434/// them (a lone object is accepted as a one-element array) through the same
435/// field mapping [`parse_json`] uses. An individual array element that is not a
436/// valid OMM object is skipped and counted in [`OmmArray::skipped`] rather than
437/// aborting the whole array. A malformed top-level document (not valid JSON, or
438/// neither an object nor an array) is still an error. Requires the `json`
439/// feature.
440pub fn parse_json_array(text: &str) -> Result<OmmArray, OmmError> {
441    use serde_json::Value;
442
443    let value: Value =
444        serde_json::from_str(text).map_err(|e| OmmError::Field(format!("malformed JSON: {e}")))?;
445    let items: &[Value] = match &value {
446        Value::Array(items) => items.as_slice(),
447        Value::Object(_) => std::slice::from_ref(&value),
448        _ => {
449            return Err(OmmError::Field(
450                "expected a JSON object or array".to_string(),
451            ))
452        }
453    };
454
455    let mut omms = Vec::with_capacity(items.len());
456    let mut skipped = 0usize;
457    for object in items {
458        match omm_from_json_value(object) {
459            Ok(omm) => omms.push(omm),
460            Err(_) => skipped += 1,
461        }
462    }
463    Ok(OmmArray { omms, skipped })
464}
465
466/// Encode an [`Omm`] as a CCSDS/CelesTrak OMM JSON object.
467///
468/// Numeric element values are emitted as JSON numbers (round-tripping the exact
469/// `f64`), strings as JSON strings, and the epoch as an ISO-8601 string, so
470/// parsing the output reproduces the same [`Omm`]. Requires the `json` feature.
471pub fn encode_json(omm: &Omm) -> String {
472    use serde_json::{Map, Number, Value};
473
474    let num = |x: f64| Number::from_f64(x).map_or(Value::Null, Value::Number);
475    let opt = |value: &Option<String>| value.clone().map_or(Value::Null, Value::String);
476
477    let mut map = Map::new();
478    map.insert(
479        "CCSDS_OMM_VERS".into(),
480        Value::String(omm.ccsds_omm_vers.clone()),
481    );
482    map.insert("CREATION_DATE".into(), opt(&omm.creation_date));
483    map.insert("ORIGINATOR".into(), opt(&omm.originator));
484    map.insert("OBJECT_NAME".into(), opt(&omm.object_name));
485    map.insert("OBJECT_ID".into(), opt(&omm.object_id));
486    map.insert("CENTER_NAME".into(), opt(&omm.center_name));
487    map.insert("REF_FRAME".into(), opt(&omm.ref_frame));
488    map.insert("TIME_SYSTEM".into(), opt(&omm.time_system));
489    map.insert("MEAN_ELEMENT_THEORY".into(), opt(&omm.mean_element_theory));
490    map.insert("EPOCH".into(), Value::String(omm.epoch.to_iso8601()));
491    map.insert("MEAN_MOTION".into(), num(omm.mean_motion));
492    map.insert("ECCENTRICITY".into(), num(omm.eccentricity));
493    map.insert("INCLINATION".into(), num(omm.inclination_deg));
494    map.insert("RA_OF_ASC_NODE".into(), num(omm.ra_of_asc_node_deg));
495    map.insert("ARG_OF_PERICENTER".into(), num(omm.arg_of_pericenter_deg));
496    map.insert("MEAN_ANOMALY".into(), num(omm.mean_anomaly_deg));
497    map.insert(
498        "EPHEMERIS_TYPE".into(),
499        Value::Number(omm.ephemeris_type.into()),
500    );
501    map.insert(
502        "CLASSIFICATION_TYPE".into(),
503        Value::String(omm.classification_type.clone()),
504    );
505    map.insert(
506        "NORAD_CAT_ID".into(),
507        Value::Number(omm.norad_cat_id.into()),
508    );
509    map.insert(
510        "ELEMENT_SET_NO".into(),
511        Value::Number(omm.element_set_no.into()),
512    );
513    map.insert(
514        "REV_AT_EPOCH".into(),
515        Value::Number(omm.rev_at_epoch.into()),
516    );
517    map.insert("BSTAR".into(), num(omm.bstar));
518    map.insert("MEAN_MOTION_DOT".into(), num(omm.mean_motion_dot));
519    map.insert("MEAN_MOTION_DDOT".into(), num(omm.mean_motion_ddot));
520    Value::Object(map).to_string()
521}
522
523/// Render a JSON scalar as the string the shared field mapping consumes. Numbers
524/// use their canonical decimal form; strings pass through; null becomes empty.
525fn json_scalar_to_string(value: &serde_json::Value) -> String {
526    use serde_json::Value;
527    match value {
528        Value::String(s) => s.clone(),
529        Value::Number(n) => n.to_string(),
530        Value::Bool(b) => b.to_string(),
531        Value::Null => String::new(),
532        other => other.to_string(),
533    }
534}
535
536// ── Encoding auto-detect ─────────────────────────────────────────────
537
538/// Parse an OMM in any supported encoding, detecting it from the leading
539/// non-whitespace character: `<` is XML, `{` or `[` is JSON, anything else is
540/// KVN. JSON requires the `json` feature; without it a JSON document returns an
541/// error rather than being misread.
542pub fn parse(text: &str) -> Result<Omm, OmmError> {
543    match text.trim_start().chars().next() {
544        Some('<') => parse_xml(text),
545        Some('{') | Some('[') => parse_json_detected(text),
546        _ => parse_kvn(text),
547    }
548}
549
550/// Parse a single CCSDS `EPOCH` string field to the canonical [`OmmEpoch`].
551///
552/// The accepted form is `YYYY-MM-DDThh:mm:ss[.ffffff][Z]`, interpreted under the
553/// UTC-like civil-second policy (the OMM default when no `TIME_SYSTEM` is
554/// declared, matching how a CelesTrak GP `EPOCH` is read). This is the single
555/// public entry point a thin binding (for example the Elixir constellation NIF)
556/// delegates to instead of hand-rolling the split; it wraps the same
557/// [`OmmEpoch`]/`NdmEpoch` parser the full OMM decode uses, so it produces
558/// byte-identical components.
559pub fn parse_epoch(text: &str) -> Result<OmmEpoch, OmmError> {
560    OmmEpoch::parse(text, validate::CivilSecondPolicy::UtcLike)
561}
562
563fn parse_json_detected(text: &str) -> Result<Omm, OmmError> {
564    parse_json(text)
565}
566
567// ── Field mapping (shared by every encoding) ─────────────────────────
568
569impl Omm {
570    /// Build an [`Omm`] from the decoded CCSDS field set: a list of
571    /// `(key, value)` string pairs as produced by any of the encodings. This is
572    /// the single place the CCSDS field names map onto the canonical container.
573    pub(crate) fn from_field_map(map: &crate::format::kvn::FieldMap) -> Result<Omm, OmmError> {
574        let get = |key: &str| map.get(key);
575
576        let time_system = xml_text(get("TIME_SYSTEM"), "TIME_SYSTEM")?;
577        let epoch = OmmEpoch::parse(
578            get("EPOCH").ok_or(OmmError::MissingField("EPOCH"))?,
579            omm_civil_second_policy(time_system.as_deref()),
580        )?;
581
582        Ok(Omm {
583            ccsds_omm_vers: xml_text_or_default(get("CCSDS_OMM_VERS"), "CCSDS_OMM_VERS", "2.0")?,
584            creation_date: xml_text(get("CREATION_DATE"), "CREATION_DATE")?,
585            originator: xml_text(get("ORIGINATOR"), "ORIGINATOR")?,
586            object_name: xml_text(get("OBJECT_NAME"), "OBJECT_NAME")?,
587            object_id: xml_text(get("OBJECT_ID"), "OBJECT_ID")?,
588            center_name: xml_text(get("CENTER_NAME"), "CENTER_NAME")?,
589            ref_frame: xml_text(get("REF_FRAME"), "REF_FRAME")?,
590            time_system,
591            mean_element_theory: xml_text(get("MEAN_ELEMENT_THEORY"), "MEAN_ELEMENT_THEORY")?,
592            epoch,
593            mean_motion: req_num(get("MEAN_MOTION"), "MEAN_MOTION")?,
594            eccentricity: req_num(get("ECCENTRICITY"), "ECCENTRICITY")?,
595            inclination_deg: req_num(get("INCLINATION"), "INCLINATION")?,
596            ra_of_asc_node_deg: req_num(get("RA_OF_ASC_NODE"), "RA_OF_ASC_NODE")?,
597            arg_of_pericenter_deg: req_num(get("ARG_OF_PERICENTER"), "ARG_OF_PERICENTER")?,
598            mean_anomaly_deg: req_num(get("MEAN_ANOMALY"), "MEAN_ANOMALY")?,
599            ephemeris_type: opt_int(get("EPHEMERIS_TYPE"), "EPHEMERIS_TYPE")?.unwrap_or(0),
600            classification_type: xml_text_or_default(
601                get("CLASSIFICATION_TYPE"),
602                "CLASSIFICATION_TYPE",
603                "U",
604            )?,
605            norad_cat_id: req_int(get("NORAD_CAT_ID"), "NORAD_CAT_ID")?,
606            element_set_no: opt_int(get("ELEMENT_SET_NO"), "ELEMENT_SET_NO")?.unwrap_or(999),
607            rev_at_epoch: opt_int(get("REV_AT_EPOCH"), "REV_AT_EPOCH")?.unwrap_or(0),
608            bstar: req_num(get("BSTAR"), "BSTAR")?,
609            mean_motion_dot: req_num(get("MEAN_MOTION_DOT"), "MEAN_MOTION_DOT")?,
610            mean_motion_ddot: req_num(get("MEAN_MOTION_DDOT"), "MEAN_MOTION_DDOT")?,
611        })
612    }
613}
614
615fn xml_text(value: Option<&str>, field: &'static str) -> Result<Option<String>, OmmError> {
616    value
617        .map(|value| xml_text_value(value, field).map(str::to_string))
618        .transpose()
619}
620
621fn xml_text_or_default(
622    value: Option<&str>,
623    field: &'static str,
624    default: &'static str,
625) -> Result<String, OmmError> {
626    xml_text_value(value.unwrap_or(default), field).map(str::to_string)
627}
628
629fn xml_text_value<'a>(value: &'a str, field: &'static str) -> Result<&'a str, OmmError> {
630    if let Some(ch) = xml::first_illegal_xml_1_0_char(value) {
631        return Err(OmmError::Field(format!(
632            "field {field} contains XML-illegal character U+{:04X}",
633            ch as u32
634        )));
635    }
636    Ok(value)
637}
638
639// ── SGP4 bridge ──────────────────────────────────────────────────────
640
641impl Omm {
642    /// Convert the canonical OMM elements into the SGP4 [`ElementSet`] consumed
643    /// by [`Satellite::from_elements`].
644    ///
645    /// The epoch is converted directly from the OMM calendar timestamp into
646    /// SGP4's split Julian date, preserving years outside the TLE pivot range.
647    /// B\* and the second mean-motion derivative are quantized onto the TLE
648    /// assumed-decimal grid because those GP parameters originate in that field
649    /// format.
650    pub fn to_element_set(&self) -> Result<ElementSet, OmmError> {
651        validate_omm_bridge(self)?;
652        Ok(ElementSet {
653            epoch: self.epoch.sgp4_julian_date(),
654            bstar: tle::assumed_decimal_quantize(self.bstar),
655            mean_motion_dot: self.mean_motion_dot,
656            mean_motion_double_dot: tle::assumed_decimal_quantize(self.mean_motion_ddot),
657            eccentricity: self.eccentricity,
658            argument_of_perigee_deg: self.arg_of_pericenter_deg,
659            inclination_deg: self.inclination_deg,
660            mean_anomaly_deg: self.mean_anomaly_deg,
661            mean_motion_rev_per_day: self.mean_motion,
662            right_ascension_deg: self.ra_of_asc_node_deg,
663            catalog_number: self.norad_cat_id,
664        })
665    }
666}
667
668impl Satellite {
669    /// Build a propagation-ready [`Satellite`] from an [`Omm`].
670    ///
671    /// Bridges the OMM mean elements into the validated SGP4 element path via
672    /// [`Omm::to_element_set`].
673    pub fn from_omm(omm: &Omm) -> Result<Self, Sgp4Error> {
674        let elements = omm.to_element_set().map_err(map_omm_bridge_to_sgp4)?;
675        Self::from_elements(&elements)
676    }
677}
678
679fn validate_omm_bridge(omm: &Omm) -> Result<(), OmmError> {
680    validate::finite_positive(omm.mean_motion, "mean_motion").map_err(map_omm_field_error)?;
681    validate::finite_in_range_exclusive_upper(omm.eccentricity, 0.0, 1.0, "eccentricity")
682        .map_err(map_omm_field_error)?;
683    validate::finite(omm.inclination_deg, "inclination_deg").map_err(map_omm_field_error)?;
684    validate::finite(omm.ra_of_asc_node_deg, "ra_of_asc_node_deg").map_err(map_omm_field_error)?;
685    validate::finite(omm.arg_of_pericenter_deg, "arg_of_pericenter_deg")
686        .map_err(map_omm_field_error)?;
687    validate::finite(omm.mean_anomaly_deg, "mean_anomaly_deg").map_err(map_omm_field_error)?;
688    validate::finite(omm.bstar, "bstar").map_err(map_omm_field_error)?;
689    validate::finite(omm.mean_motion_dot, "mean_motion_dot").map_err(map_omm_field_error)?;
690    validate::finite(omm.mean_motion_ddot, "mean_motion_ddot").map_err(map_omm_field_error)?;
691    Ok(())
692}
693
694fn map_omm_bridge_to_sgp4(error: OmmError) -> Sgp4Error {
695    match error {
696        OmmError::InvalidField { field, kind } => Sgp4Error::InvalidInput {
697            field,
698            kind: match kind {
699                OmmInputErrorKind::NonFinite => Sgp4InputErrorKind::NonFinite,
700                OmmInputErrorKind::NotPositive => Sgp4InputErrorKind::NotPositive,
701                OmmInputErrorKind::Negative => Sgp4InputErrorKind::Negative,
702                OmmInputErrorKind::OutOfRange => Sgp4InputErrorKind::OutOfRange,
703                OmmInputErrorKind::Missing => Sgp4InputErrorKind::Missing,
704                OmmInputErrorKind::FloatParse => Sgp4InputErrorKind::FloatParse,
705                OmmInputErrorKind::IntParse => Sgp4InputErrorKind::IntParse,
706                OmmInputErrorKind::InvalidCivilDate => Sgp4InputErrorKind::InvalidCivilDate,
707                OmmInputErrorKind::InvalidCivilTime => Sgp4InputErrorKind::InvalidCivilTime,
708            },
709        },
710        other => Sgp4Error::InvalidTle(other.to_string()),
711    }
712}
713
714// ── Epoch ────────────────────────────────────────────────────────────
715
716impl OmmEpoch {
717    /// Parse a CCSDS `EPOCH` value (`YYYY-MM-DDThh:mm:ss[.ffffff][Z]`, UTC).
718    fn parse(text: &str, second_policy: validate::CivilSecondPolicy) -> Result<OmmEpoch, OmmError> {
719        let e = crate::astro::ndm::NdmEpoch::parse(text, second_policy)
720            .map_err(|err| map_omm_epoch_field_error(err, text))?;
721        Ok(OmmEpoch {
722            year: e.year,
723            month: e.month,
724            day: e.day,
725            hour: e.hour,
726            minute: e.minute,
727            second: e.second,
728            microsecond: e.microsecond,
729        })
730    }
731
732    /// Convert directly to the SGP4 split Julian date from the full OMM
733    /// calendar timestamp.
734    fn sgp4_julian_date(&self) -> sgp4::JulianDate {
735        sgp4::sgp4_julian_date_from_calendar(
736            self.year,
737            self.month as i32,
738            self.day as i32,
739            self.hour as i32,
740            self.minute as i32,
741            self.second as f64 + self.microsecond as f64 / 1_000_000.0,
742        )
743    }
744
745    /// Format as a CCSDS `EPOCH` string with microsecond precision.
746    fn to_iso8601(&self) -> String {
747        format!(
748            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}",
749            self.year, self.month, self.day, self.hour, self.minute, self.second, self.microsecond
750        )
751    }
752}
753
754// ── Numeric helpers ──────────────────────────────────────────────────
755
756fn omm_civil_second_policy(time_system: Option<&str>) -> validate::CivilSecondPolicy {
757    let Some(label) = time_system.map(str::trim).filter(|label| !label.is_empty()) else {
758        return validate::CivilSecondPolicy::UtcLike;
759    };
760    if label.eq_ignore_ascii_case("UTC")
761        || label.eq_ignore_ascii_case("GLO")
762        || label.eq_ignore_ascii_case("GLONASS")
763    {
764        validate::CivilSecondPolicy::UtcLike
765    } else {
766        validate::CivilSecondPolicy::Continuous
767    }
768}
769
770fn req_num(value: Option<&str>, field: &'static str) -> Result<f64, OmmError> {
771    let value = value.ok_or(OmmError::MissingField(field))?;
772    parse_num(value, field)
773}
774
775fn parse_num(value: &str, field: &'static str) -> Result<f64, OmmError> {
776    validate::strict_f64(value, field).map_err(map_omm_field_error)
777}
778
779fn req_int<T>(value: Option<&str>, field: &'static str) -> Result<T, OmmError>
780where
781    T: std::str::FromStr,
782{
783    let value = value.ok_or(OmmError::MissingField(field))?;
784    parse_int(value, field)
785}
786
787fn opt_int<T>(value: Option<&str>, field: &'static str) -> Result<Option<T>, OmmError>
788where
789    T: std::str::FromStr,
790{
791    value.map(|v| parse_int(v, field)).transpose()
792}
793
794fn parse_int<T>(value: &str, field: &'static str) -> Result<T, OmmError>
795where
796    T: std::str::FromStr,
797{
798    validate::strict_int::<T>(value, field).map_err(map_omm_field_error)
799}
800
801fn map_omm_field_error(error: validate::FieldError) -> OmmError {
802    OmmError::InvalidField {
803        field: error.field(),
804        kind: OmmInputErrorKind::from(&error),
805    }
806}
807
808fn map_omm_epoch_field_error(error: validate::FieldError, full: &str) -> OmmError {
809    match error {
810        validate::FieldError::Missing { .. }
811        | validate::FieldError::FloatParse { .. }
812        | validate::FieldError::IntParse { .. } => {
813            OmmError::Epoch(format!("invalid seconds in {full:?}"))
814        }
815        _ => map_omm_field_error(error),
816    }
817}
818
819/// Shortest decimal form of a value that round-trips back to the same `f64`.
820fn fmt_num(value: f64) -> String {
821    format!("{value}")
822}
823
824#[cfg(all(test, sidereon_repo_tests))]
825mod tests {
826    use super::*;
827
828    const ISS_KVN: &str = include_str!("../../tests/fixtures/omm/25544.kvn");
829    const ISS_XML: &str = include_str!("../../tests/fixtures/omm/25544.xml");
830
831    /// Reduce an OMM to its canonical orbital + catalog content (the fields the
832    /// Elixir `Sidereon.Elements` struct carries), blanking the free-text header
833    /// metadata that CelesTrak emits inconsistently across encodings: it labels
834    /// the element theory `SGP/SGP4` in KVN but `SGP4` in XML/JSON, and its JSON
835    /// omits `CENTER_NAME`/`REF_FRAME`/`TIME_SYSTEM` entirely. Cross-encoding
836    /// identity is asserted on this canonical content, which must match exactly.
837    fn canonical(omm: &Omm) -> Omm {
838        Omm {
839            ccsds_omm_vers: String::new(),
840            creation_date: None,
841            originator: None,
842            center_name: None,
843            ref_frame: None,
844            time_system: None,
845            mean_element_theory: None,
846            ..omm.clone()
847        }
848    }
849
850    fn kvn_with_field(field: &str, value: &str) -> String {
851        kvn_with_fields(&[(field, value)])
852    }
853
854    fn kvn_with_fields(fields: &[(&str, &str)]) -> String {
855        ISS_KVN
856            .lines()
857            .map(|line| match line.split_once('=') {
858                Some((key, _)) => fields
859                    .iter()
860                    .find(|(field, _)| key.trim() == *field)
861                    .map_or_else(
862                        || line.to_string(),
863                        |(field, value)| format!("{field} = {value}"),
864                    ),
865                _ => line.to_string(),
866            })
867            .collect::<Vec<_>>()
868            .join("\n")
869    }
870
871    fn kvn_without_field(field: &str) -> String {
872        ISS_KVN
873            .lines()
874            .filter(|line| match line.split_once('=') {
875                Some((key, _)) => key.trim() != field,
876                None => true,
877            })
878            .collect::<Vec<_>>()
879            .join("\n")
880    }
881
882    #[test]
883    fn parses_iss_kvn_fields() {
884        let omm = parse_kvn(ISS_KVN).unwrap();
885        assert_eq!(omm.ccsds_omm_vers, "2.0");
886        assert_eq!(omm.object_name.as_deref(), Some("ISS (ZARYA)"));
887        assert_eq!(omm.object_id.as_deref(), Some("1998-067A"));
888        assert_eq!(omm.norad_cat_id, 25544);
889        assert_eq!(omm.mean_motion, 15.49273435);
890        assert_eq!(omm.eccentricity, 0.0004737);
891        assert_eq!(omm.inclination_deg, 51.6332);
892        assert_eq!(omm.bstar, 0.00017172);
893        assert_eq!(omm.mean_motion_dot, 9.113e-5);
894        assert_eq!(omm.mean_motion_ddot, 0.0);
895        assert_eq!(
896            omm.epoch,
897            OmmEpoch {
898                year: 2026,
899                month: 6,
900                day: 17,
901                hour: 4,
902                minute: 32,
903                second: 52,
904                microsecond: 99296,
905            }
906        );
907    }
908
909    #[test]
910    fn parse_kvn_requires_drag_terms() {
911        for field in ["BSTAR", "MEAN_MOTION_DOT", "MEAN_MOTION_DDOT"] {
912            assert_eq!(
913                parse_kvn(&kvn_without_field(field)),
914                Err(OmmError::MissingField(field))
915            );
916        }
917    }
918
919    #[test]
920    fn parse_kvn_rejects_non_finite_drag_terms() {
921        for field in ["BSTAR", "MEAN_MOTION_DOT", "MEAN_MOTION_DDOT"] {
922            assert_eq!(
923                parse_kvn(&kvn_with_field(field, "NaN")),
924                Err(OmmError::InvalidField {
925                    field,
926                    kind: OmmInputErrorKind::NonFinite,
927                })
928            );
929        }
930    }
931
932    #[test]
933    fn parse_kvn_rejects_negative_norad_catalog_id() {
934        assert_eq!(
935            parse_kvn(&kvn_with_field("NORAD_CAT_ID", "-1")),
936            Err(OmmError::InvalidField {
937                field: "NORAD_CAT_ID",
938                kind: OmmInputErrorKind::IntParse,
939            })
940        );
941    }
942
943    #[test]
944    fn parse_kvn_rejects_oversized_norad_catalog_id() {
945        assert_eq!(
946            parse_kvn(&kvn_with_field("NORAD_CAT_ID", "4294967296")),
947            Err(OmmError::InvalidField {
948                field: "NORAD_CAT_ID",
949                kind: OmmInputErrorKind::IntParse,
950            })
951        );
952    }
953
954    #[test]
955    fn parse_kvn_rejects_invalid_civil_epoch() {
956        assert_eq!(
957            parse_kvn(&kvn_with_field("EPOCH", "2026-02-30T04:32:52.099296")),
958            Err(OmmError::InvalidField {
959                field: "civil datetime",
960                kind: OmmInputErrorKind::InvalidCivilDate,
961            })
962        );
963        assert_eq!(
964            parse_kvn(&kvn_with_field("EPOCH", "2026-06-17T24:00:00.000000")),
965            Err(OmmError::InvalidField {
966                field: "civil datetime",
967                kind: OmmInputErrorKind::InvalidCivilTime,
968            })
969        );
970    }
971
972    #[test]
973    fn parse_kvn_accepts_utc_leap_second_epoch() {
974        let omm = parse_kvn(&kvn_with_field("EPOCH", "2016-12-31T23:59:60.000000Z"))
975            .expect("OMM leap-second epoch");
976        assert_eq!(
977            omm.epoch,
978            OmmEpoch {
979                year: 2016,
980                month: 12,
981                day: 31,
982                hour: 23,
983                minute: 59,
984                second: 60,
985                microsecond: 0,
986            }
987        );
988    }
989
990    #[test]
991    fn parse_kvn_rejects_gps_time_leap_second_epoch() {
992        assert_eq!(
993            parse_kvn(&kvn_with_fields(&[
994                ("TIME_SYSTEM", "GPS"),
995                ("EPOCH", "2016-12-31T23:59:60.000000Z"),
996            ])),
997            Err(OmmError::InvalidField {
998                field: "civil datetime",
999                kind: OmmInputErrorKind::InvalidCivilTime,
1000            })
1001        );
1002    }
1003
1004    #[test]
1005    fn parse_kvn_rejects_invalid_leap_second_range() {
1006        assert!(parse_kvn(&kvn_with_field("EPOCH", "2016-12-31T23:59:61.000000Z")).is_err());
1007        assert!(parse_kvn(&kvn_with_field("EPOCH", "2016-12-31T23:59:-1.000000Z")).is_err());
1008    }
1009
1010    #[test]
1011    fn parse_kvn_requires_fractional_epoch_digits() {
1012        let omm = parse_kvn(&kvn_with_field("EPOCH", "2026-06-17T04:32:52.500"))
1013            .expect("fractional epoch");
1014        assert_eq!(omm.epoch.microsecond, 500_000);
1015
1016        let omm = parse_kvn(&kvn_with_field("EPOCH", "2026-06-17T04:32:52.5Z"))
1017            .expect("fractional epoch with UTC suffix");
1018        assert_eq!(omm.epoch.microsecond, 500_000);
1019
1020        for epoch in [
1021            "2026-06-17T04:32:52.abc",
1022            "2026-06-17T04:32:52.abcZ",
1023            "2026-06-17T04:32:52.5x",
1024            "2026-06-17T04:32:52.5xZ",
1025            "2026-06-17T04:32:52.",
1026        ] {
1027            assert!(
1028                matches!(
1029                    parse_kvn(&kvn_with_field("EPOCH", epoch)),
1030                    Err(OmmError::Epoch(_))
1031                ),
1032                "{epoch} must be rejected"
1033            );
1034        }
1035    }
1036
1037    #[test]
1038    fn parse_kvn_carries_rounded_fractional_epoch_seconds() {
1039        let omm = parse_kvn(&kvn_with_field("EPOCH", "2026-06-17T04:32:52.9999995"))
1040            .expect("fractional epoch carry");
1041        assert_eq!(
1042            omm.epoch,
1043            OmmEpoch {
1044                year: 2026,
1045                month: 6,
1046                day: 17,
1047                hour: 4,
1048                minute: 32,
1049                second: 53,
1050                microsecond: 0,
1051            }
1052        );
1053        assert!(
1054            encode_kvn(&omm).contains("EPOCH = 2026-06-17T04:32:53.000000"),
1055            "carried epoch must encode with six fractional digits"
1056        );
1057    }
1058
1059    #[test]
1060    fn parse_kvn_carries_continuous_time_fractional_epoch_across_day() {
1061        let omm = parse_kvn(&kvn_with_fields(&[
1062            ("TIME_SYSTEM", "GPS"),
1063            ("EPOCH", "2026-06-17T23:59:59.9999995"),
1064        ]))
1065        .expect("continuous-time fractional epoch carry");
1066        assert_eq!(
1067            omm.epoch,
1068            OmmEpoch {
1069                year: 2026,
1070                month: 6,
1071                day: 18,
1072                hour: 0,
1073                minute: 0,
1074                second: 0,
1075                microsecond: 0,
1076            }
1077        );
1078    }
1079
1080    #[test]
1081    fn parse_kvn_carries_continuous_time_fractional_epoch_across_year() {
1082        let ordinary = parse_kvn(&kvn_with_fields(&[
1083            ("TIME_SYSTEM", "GPS"),
1084            ("EPOCH", "2026-12-31T23:59:58.123456"),
1085        ]))
1086        .expect("ordinary continuous-time epoch");
1087        assert_eq!(
1088            ordinary.epoch,
1089            OmmEpoch {
1090                year: 2026,
1091                month: 12,
1092                day: 31,
1093                hour: 23,
1094                minute: 59,
1095                second: 58,
1096                microsecond: 123_456,
1097            }
1098        );
1099        assert!(
1100            encode_kvn(&ordinary).contains("EPOCH = 2026-12-31T23:59:58.123456"),
1101            "ordinary epoch must encode unchanged"
1102        );
1103
1104        let carried = parse_kvn(&kvn_with_fields(&[
1105            ("TIME_SYSTEM", "GPS"),
1106            ("EPOCH", "2026-12-31T23:59:59.9999995"),
1107        ]))
1108        .expect("continuous-time fractional epoch carry across year");
1109        assert_eq!(
1110            carried.epoch,
1111            OmmEpoch {
1112                year: 2027,
1113                month: 1,
1114                day: 1,
1115                hour: 0,
1116                minute: 0,
1117                second: 0,
1118                microsecond: 0,
1119            }
1120        );
1121        assert!(
1122            encode_kvn(&carried).contains("EPOCH = 2027-01-01T00:00:00.000000"),
1123            "carried epoch must encode with the next year"
1124        );
1125    }
1126
1127    #[test]
1128    fn kvn_round_trips_through_struct() {
1129        let omm = parse_kvn(ISS_KVN).unwrap();
1130        let reparsed = parse_kvn(&encode_kvn(&omm)).unwrap();
1131        assert_eq!(omm, reparsed);
1132    }
1133
1134    #[test]
1135    fn xml_matches_kvn_orbital_content() {
1136        let kvn = parse_kvn(ISS_KVN).unwrap();
1137        let xml = parse_xml(ISS_XML).unwrap();
1138        assert_eq!(canonical(&kvn), canonical(&xml));
1139    }
1140
1141    #[test]
1142    fn xml_round_trips_through_struct() {
1143        let omm = parse_xml(ISS_XML).unwrap();
1144        let reparsed = parse_xml(&encode_xml(&omm)).unwrap();
1145        assert_eq!(omm, reparsed);
1146    }
1147
1148    #[test]
1149    fn parse_kvn_rejects_xml_illegal_text_controls() {
1150        let err = parse_kvn(&kvn_with_field("MEAN_ELEMENT_THEORY", "SGP\u{0005}SGP4"))
1151            .expect_err("XML-illegal control characters must not enter OMM text fields");
1152        assert_eq!(
1153            err,
1154            OmmError::Field(
1155                "field MEAN_ELEMENT_THEORY contains XML-illegal character U+0005".to_string()
1156            )
1157        );
1158
1159        let omm = parse_kvn(&kvn_with_field("MEAN_ELEMENT_THEORY", "SGP\tSGP4"))
1160            .expect("XML-legal text control must remain valid");
1161        let reparsed = parse_xml(&encode_xml(&omm)).expect("encoded OMM must remain valid XML");
1162        assert_eq!(omm, reparsed);
1163    }
1164
1165    #[test]
1166    fn xml_round_trip_preserves_carriage_returns_in_text_values() {
1167        for value in ["SGP\rSGP4", "SGP\r\nSGP4"] {
1168            let mut omm = parse_kvn(ISS_KVN).expect("base OMM must parse");
1169            omm.mean_element_theory = Some(value.to_string());
1170            let encoded = encode_xml(&omm);
1171            assert!(encoded.contains("&#xD;"));
1172            assert!(!encoded.contains('\r'));
1173            let reparsed = parse_xml(&encoded).expect("encoded OMM must remain valid XML");
1174            assert_eq!(omm.mean_element_theory, reparsed.mean_element_theory);
1175            assert_eq!(omm, reparsed);
1176        }
1177    }
1178
1179    #[test]
1180    fn json_matches_kvn_orbital_content() {
1181        const ISS_JSON: &str = include_str!("../../tests/fixtures/omm/25544.json");
1182        let kvn = parse_kvn(ISS_KVN).unwrap();
1183        let json = parse_json(ISS_JSON).unwrap();
1184        assert_eq!(canonical(&kvn), canonical(&json));
1185    }
1186
1187    #[test]
1188    fn json_round_trips_through_struct() {
1189        const ISS_JSON: &str = include_str!("../../tests/fixtures/omm/25544.json");
1190        let omm = parse_json(ISS_JSON).unwrap();
1191        let reparsed = parse_json(&encode_json(&omm)).unwrap();
1192        assert_eq!(omm, reparsed);
1193    }
1194
1195    #[test]
1196    fn parse_auto_detects_encoding() {
1197        let from_kvn = parse(ISS_KVN).unwrap();
1198        let from_xml = parse(ISS_XML).unwrap();
1199        assert_eq!(parse_kvn(ISS_KVN).unwrap(), from_kvn);
1200        assert_eq!(parse_xml(ISS_XML).unwrap(), from_xml);
1201        assert_eq!(canonical(&from_kvn), canonical(&from_xml));
1202    }
1203
1204    #[test]
1205    fn parse_auto_detects_json_array() {
1206        const ISS_JSON: &str = include_str!("../../tests/fixtures/omm/25544.json");
1207        // CelesTrak JSON is a top-level array.
1208        assert_eq!(parse(ISS_JSON).unwrap(), parse_json(ISS_JSON).unwrap());
1209    }
1210
1211    #[test]
1212    fn parse_json_array_skips_malformed_objects_and_counts_them() {
1213        // A CelesTrak-shaped array with two good OMMs interleaved with a
1214        // non-object element and a malformed object (missing the required drag
1215        // terms). One bad object must not reject the whole array: the good
1216        // records survive and the skips are surfaced in `skipped`.
1217        let good = |norad: u32, id: &str| {
1218            format!(
1219                r#"{{"OBJECT_NAME":"SAT","OBJECT_ID":"{id}","EPOCH":"2026-06-17T04:32:52.099296","MEAN_MOTION":15.49273435,"ECCENTRICITY":0.0004737,"INCLINATION":51.6332,"RA_OF_ASC_NODE":300.0813,"ARG_OF_PERICENTER":195.1146,"MEAN_ANOMALY":164.9702,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":{norad},"ELEMENT_SET_NO":999,"REV_AT_EPOCH":57175,"BSTAR":0.00017172,"MEAN_MOTION_DOT":9.113e-5,"MEAN_MOTION_DDOT":0}}"#
1220            )
1221        };
1222        let text = format!(
1223            "[{}, \"not an object\", {{\"OBJECT_NAME\":\"BROKEN\",\"NORAD_CAT_ID\":99999}}, {}]",
1224            good(25544, "1998-067A"),
1225            good(25545, "1998-067B"),
1226        );
1227
1228        let result = parse_json_array(&text).expect("array with bad entries must still parse");
1229        assert_eq!(result.skipped, 2, "the string and the malformed object");
1230        let norads: Vec<u32> = result.omms.iter().map(|o| o.norad_cat_id).collect();
1231        assert_eq!(norads, vec![25544, 25545], "both good OMMs must survive");
1232    }
1233
1234    #[test]
1235    fn bstar_quantizes_onto_assumed_decimal_grid() {
1236        // OMM B* is the plain-decimal 0.00017172; the SGP4 element set must carry
1237        // the assumed-decimal value 0.17172e-3 the TLE actually feeds SGP4.
1238        let omm = parse_kvn(ISS_KVN).unwrap();
1239        let es = omm.to_element_set().expect("valid OMM bridge");
1240        assert_eq!(es.bstar, 0.17172 * 10.0_f64.powi(-3));
1241        assert_ne!(es.bstar, omm.bstar);
1242    }
1243
1244    #[test]
1245    fn to_element_set_rejects_invalid_bridge_fields() {
1246        let mut omm = parse_kvn(ISS_KVN).unwrap();
1247        omm.mean_motion = f64::NAN;
1248        assert_eq!(
1249            omm.to_element_set(),
1250            Err(OmmError::InvalidField {
1251                field: "mean_motion",
1252                kind: OmmInputErrorKind::NonFinite
1253            })
1254        );
1255
1256        let mut omm = parse_kvn(ISS_KVN).unwrap();
1257        omm.eccentricity = 1.0;
1258        assert_eq!(
1259            omm.to_element_set(),
1260            Err(OmmError::InvalidField {
1261                field: "eccentricity",
1262                kind: OmmInputErrorKind::OutOfRange
1263            })
1264        );
1265    }
1266
1267    #[test]
1268    fn from_omm_preserves_epoch_year_outside_tle_pivot_range() {
1269        let omm = parse_kvn(&kvn_with_field("EPOCH", "2057-01-01T00:00:00.000000"))
1270            .expect("future OMM epoch");
1271        let sat = Satellite::from_omm(&omm).expect("OMM with full-year epoch must initialize");
1272
1273        let epoch = sat.epoch_jd();
1274        let actual_jd = epoch.0 + epoch.1;
1275        let expected_jd = crate::astro::time::scales::julian_day_number(2057, 1, 1) as f64 - 0.5;
1276        let aliased_1957_jd =
1277            crate::astro::time::scales::julian_day_number(1957, 1, 1) as f64 - 0.5;
1278
1279        assert!(
1280            (actual_jd - expected_jd).abs() < 1.0e-9,
1281            "OMM epoch JD {actual_jd} must match the true 2057 epoch {expected_jd}",
1282        );
1283        assert!(
1284            (actual_jd - aliased_1957_jd).abs() > 36_000.0,
1285            "OMM epoch JD {actual_jd} must not alias to 1957 {aliased_1957_jd}",
1286        );
1287    }
1288
1289    #[test]
1290    fn from_omm_uses_parser_rounded_year_end_epoch_directly() {
1291        for (epoch, expected_year) in [
1292            ("2021-12-31T23:59:59.9999995", 2022),
1293            ("2020-12-31T23:59:59.9999995", 2021),
1294        ] {
1295            let omm = parse_kvn(&kvn_with_field("EPOCH", epoch)).expect("year-end OMM epoch");
1296            assert_eq!(omm.epoch.year, expected_year);
1297            assert_eq!(omm.epoch.month, 1);
1298            assert_eq!(omm.epoch.day, 1);
1299            assert_eq!(omm.epoch.hour, 0);
1300            assert_eq!(omm.epoch.minute, 0);
1301            assert_eq!(omm.epoch.second, 0);
1302            assert_eq!(omm.epoch.microsecond, 0);
1303
1304            let sat = Satellite::from_omm(&omm).expect("rounded year-end OMM must initialize");
1305            let epoch_jd = sat.epoch_jd();
1306            let actual_jd = epoch_jd.0 + epoch_jd.1;
1307            let expected_jd =
1308                crate::astro::time::scales::julian_day_number(expected_year, 1, 1) as f64 - 0.5;
1309
1310            assert!(
1311                (actual_jd - expected_jd).abs() < 1.0e-9,
1312                "{epoch} carried to JD {actual_jd}, expected {expected_jd}",
1313            );
1314        }
1315    }
1316
1317    #[test]
1318    fn from_omm_rejects_invalid_sgp4_element_fields() {
1319        let mut omm = parse_kvn(ISS_KVN).unwrap();
1320        omm.mean_motion = f64::NAN;
1321        let err = Satellite::from_omm(&omm).expect_err("non-finite mean motion must error");
1322        assert_eq!(
1323            err,
1324            Sgp4Error::InvalidInput {
1325                field: "mean_motion",
1326                kind: crate::astro::sgp4::Sgp4InputErrorKind::NonFinite,
1327            }
1328        );
1329
1330        let mut omm = parse_kvn(ISS_KVN).unwrap();
1331        omm.eccentricity = 1.0;
1332        let err = Satellite::from_omm(&omm).expect_err("eccentricity >= 1 must error");
1333        assert_eq!(
1334            err,
1335            Sgp4Error::InvalidInput {
1336                field: "eccentricity",
1337                kind: crate::astro::sgp4::Sgp4InputErrorKind::OutOfRange,
1338            }
1339        );
1340    }
1341}