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::{
30    self, ElementSet, Error as Sgp4Error, JulianDate, Satellite, Sgp4InputErrorKind,
31};
32use crate::astro::tle;
33use crate::astro::xml;
34use crate::validate;
35use roxmltree::Document;
36use std::fmt::{self, Write as _};
37
38/// Leaf CCSDS field element names carried in an OMM XML message (the format-set
39/// version, `CCSDS_OMM_VERS`, is an attribute on `<omm>` and is handled
40/// separately). Decoding maps each onto the shared `(key, value)` field set, the
41/// same one the KVN tokenizer produces.
42const FIELD_TAGS: &[&str] = &[
43    "CREATION_DATE",
44    "ORIGINATOR",
45    "OBJECT_NAME",
46    "OBJECT_ID",
47    "CENTER_NAME",
48    "REF_FRAME",
49    "TIME_SYSTEM",
50    "MEAN_ELEMENT_THEORY",
51    "EPOCH",
52    "MEAN_MOTION",
53    "ECCENTRICITY",
54    "INCLINATION",
55    "RA_OF_ASC_NODE",
56    "ARG_OF_PERICENTER",
57    "MEAN_ANOMALY",
58    "EPHEMERIS_TYPE",
59    "CLASSIFICATION_TYPE",
60    "NORAD_CAT_ID",
61    "ELEMENT_SET_NO",
62    "REV_AT_EPOCH",
63    "BSTAR",
64    "MEAN_MOTION_DOT",
65    "MEAN_MOTION_DDOT",
66];
67
68/// UTC calendar epoch as carried by an OMM, split into the components a KVN/XML
69/// `EPOCH` (or JSON `EPOCH`) string spells out. Stored as integers so the epoch
70/// re-encodes losslessly and converts directly to the SGP4 epoch.
71#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
72pub struct OmmEpoch {
73    pub year: i32,
74    pub month: u32,
75    pub day: u32,
76    pub hour: u32,
77    pub minute: u32,
78    pub second: u32,
79    /// Fractional second expressed in whole microseconds (0..=999_999).
80    pub microsecond: u32,
81    /// Fractional remainder within `microsecond`, in whole femtoseconds
82    /// (0..=999_999_999). Ordinary catalog messages usually leave this at zero;
83    /// fitted OMMs use it to avoid losing sub-microsecond split-JD precision.
84    #[serde(default, skip_serializing_if = "is_zero_u32")]
85    pub femtosecond: u32,
86}
87
88/// Canonical, format-agnostic OMM container.
89///
90/// Pure data: it knows nothing about KVN/XML/JSON serialization. The numeric
91/// element values use standard astrodynamic units (angles in degrees, mean
92/// motion in revolutions/day, its derivatives in rev/day^2 and rev/day^3, B\*
93/// in inverse earth-radii) and are stored as directly parsed `f64`s, so every
94/// encoding decodes to the same value.
95#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
96pub struct Omm {
97    // -- Header / metadata --
98    pub ccsds_omm_vers: String,
99    pub creation_date: Option<String>,
100    pub originator: Option<String>,
101    pub object_name: Option<String>,
102    /// International designator, CCSDS form (e.g. `"1998-067A"`).
103    pub object_id: Option<String>,
104    pub center_name: Option<String>,
105    pub ref_frame: Option<String>,
106    pub time_system: Option<String>,
107    pub mean_element_theory: Option<String>,
108
109    // -- Mean elements --
110    pub epoch: OmmEpoch,
111    /// Mean motion, revolutions per day.
112    pub mean_motion: f64,
113    /// Eccentricity, dimensionless, in [0, 1).
114    pub eccentricity: f64,
115    /// Inclination, degrees.
116    pub inclination_deg: f64,
117    /// Right ascension of the ascending node, degrees.
118    pub ra_of_asc_node_deg: f64,
119    /// Argument of pericenter, degrees.
120    pub arg_of_pericenter_deg: f64,
121    /// Mean anomaly, degrees.
122    pub mean_anomaly_deg: f64,
123
124    // -- TLE-derived parameters --
125    pub ephemeris_type: i32,
126    pub classification_type: String,
127    pub norad_cat_id: u32,
128    pub element_set_no: i32,
129    pub rev_at_epoch: i64,
130    /// SGP4 drag term B\*, inverse earth-radii.
131    pub bstar: f64,
132    /// First derivative of mean motion, rev/day^2.
133    pub mean_motion_dot: f64,
134    /// Second derivative of mean motion, rev/day^3.
135    pub mean_motion_ddot: f64,
136    /// Exact split SGP4 epoch for in-memory producers that already have the
137    /// split JD. CCSDS encodings do not carry this side channel; parsed catalog
138    /// messages leave it `None` and rebuild the split from `epoch`.
139    ///
140    /// Scope: this preserves the producer's exact `(whole, fraction)` split
141    /// *in memory only*. Through encode -> reparse the epoch travels as the
142    /// femtosecond-rounded calendar text and is rebuilt as a canonical
143    /// midnight-anchored split: the same instant to femtosecond precision (and
144    /// bit-identical when the producer's split was already midnight-anchored),
145    /// but not necessarily the same split representation, which SGP4's split
146    /// tsince subtraction is sensitive to at the last ULP.
147    #[serde(default, skip)]
148    pub exact_sgp4_epoch: Option<JulianDate>,
149    /// Whether TLE-derived GP fields (B\*, the second mean-motion derivative)
150    /// should be snapped to the legacy assumed-decimal TLE grid when bridging
151    /// into SGP4. Parsed catalog OMMs default to `true`: their GP values
152    /// originate in the TLE field format, so the historical compatibility path
153    /// reproduces the value a TLE consumer would see. Fitted OMMs set this
154    /// `false`: their elements were estimated directly and never lived on the
155    /// TLE grid, so snapping would discard converged precision for no
156    /// compatibility gain; `to_element_set` then passes them through losslessly.
157    #[serde(default = "default_quantize_tle_derived_fields", skip_serializing)]
158    pub quantize_tle_derived_fields: bool,
159}
160
161/// Failure modes of the OMM readers.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub enum OmmError {
164    /// A required field was absent from the message.
165    MissingField(&'static str),
166    /// A decoded scalar field failed boundary validation.
167    InvalidField {
168        field: &'static str,
169        kind: OmmInputErrorKind,
170    },
171    /// A numeric/integer field could not be parsed.
172    Field(String),
173    /// The `EPOCH` value was malformed.
174    Epoch(String),
175}
176
177/// OMM boundary-validation failure category.
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub enum OmmInputErrorKind {
180    /// A required field was absent.
181    Missing,
182    /// A floating-point field was NaN or infinite.
183    NonFinite,
184    /// A floating-point field could not be parsed.
185    FloatParse,
186    /// An integer field could not be parsed.
187    IntParse,
188    /// A positive physical field was zero or negative.
189    NotPositive,
190    /// A non-negative physical field was negative.
191    Negative,
192    /// A finite numeric field was outside its accepted range.
193    OutOfRange,
194    /// A civil date field was out of range.
195    InvalidCivilDate,
196    /// A civil time field was out of range.
197    InvalidCivilTime,
198}
199
200impl fmt::Display for OmmInputErrorKind {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        let label = match self {
203            Self::Missing => "missing",
204            Self::NonFinite => "not finite",
205            Self::FloatParse => "invalid float",
206            Self::IntParse => "invalid integer",
207            Self::NotPositive => "not positive",
208            Self::Negative => "negative",
209            Self::OutOfRange => "out of range",
210            Self::InvalidCivilDate => "invalid civil date",
211            Self::InvalidCivilTime => "invalid civil time",
212        };
213        f.write_str(label)
214    }
215}
216
217impl From<&validate::FieldError> for OmmInputErrorKind {
218    fn from(error: &validate::FieldError) -> Self {
219        match error {
220            validate::FieldError::Missing { .. } => Self::Missing,
221            validate::FieldError::NonFinite { .. } => Self::NonFinite,
222            validate::FieldError::FloatParse { .. } => Self::FloatParse,
223            validate::FieldError::IntParse { .. } => Self::IntParse,
224            validate::FieldError::NotPositive { .. } => Self::NotPositive,
225            validate::FieldError::Negative { .. } => Self::Negative,
226            validate::FieldError::OutOfRange { .. } => Self::OutOfRange,
227            validate::FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
228            validate::FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
229        }
230    }
231}
232
233impl fmt::Display for OmmError {
234    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
235        match self {
236            OmmError::MissingField(name) => write!(f, "OMM missing required field {name}"),
237            OmmError::InvalidField { field, kind } => {
238                write!(f, "invalid OMM field {field}: {kind}")
239            }
240            OmmError::Field(msg) => write!(f, "OMM field error: {msg}"),
241            OmmError::Epoch(msg) => write!(f, "OMM epoch error: {msg}"),
242        }
243    }
244}
245
246impl std::error::Error for OmmError {}
247
248// ── KVN ──────────────────────────────────────────────────────────────
249
250/// Parse a CCSDS OMM in KVN (`KEY = VALUE`) encoding into an [`Omm`].
251///
252/// Blank lines and lines without an `=` are ignored; keys and values are
253/// trimmed. Numeric values accept the CelesTrak forms, including a leading
254/// decimal point (`.0004737`) and scientific notation (`.17172E-3`).
255pub fn parse_kvn(text: &str) -> Result<Omm, OmmError> {
256    let map = crate::format::kvn::FieldMap::parse(text);
257    Omm::from_field_map(&map)
258}
259
260/// Encode an [`Omm`] as a CCSDS OMM KVN message.
261///
262/// Numeric values use their shortest round-tripping decimal form, so parsing the
263/// output reproduces the same `f64`s. The epoch is emitted to microseconds,
264/// extended to femtoseconds only when a sub-microsecond remainder is present.
265pub fn encode_kvn(omm: &Omm) -> String {
266    let mut out = String::new();
267    let header = crate::astro::ndm::NdmHeader {
268        vers: omm.ccsds_omm_vers.clone(),
269        creation_date: omm.creation_date.clone(),
270        originator: omm.originator.clone(),
271    };
272    for line in header.write_kvn("CCSDS_OMM_VERS") {
273        out.push_str(&line);
274        out.push('\n');
275    }
276
277    let mut kv = |key: &str, value: &str| {
278        out.push_str(key);
279        out.push_str(" = ");
280        out.push_str(value);
281        out.push('\n');
282    };
283
284    kv("OBJECT_NAME", omm.object_name.as_deref().unwrap_or(""));
285    kv("OBJECT_ID", omm.object_id.as_deref().unwrap_or(""));
286    kv("CENTER_NAME", omm.center_name.as_deref().unwrap_or(""));
287    kv("REF_FRAME", omm.ref_frame.as_deref().unwrap_or(""));
288    kv("TIME_SYSTEM", omm.time_system.as_deref().unwrap_or(""));
289    kv(
290        "MEAN_ELEMENT_THEORY",
291        omm.mean_element_theory.as_deref().unwrap_or(""),
292    );
293    kv("EPOCH", &omm.epoch.to_iso8601());
294    kv("MEAN_MOTION", &fmt_num(omm.mean_motion));
295    kv("ECCENTRICITY", &fmt_num(omm.eccentricity));
296    kv("INCLINATION", &fmt_num(omm.inclination_deg));
297    kv("RA_OF_ASC_NODE", &fmt_num(omm.ra_of_asc_node_deg));
298    kv("ARG_OF_PERICENTER", &fmt_num(omm.arg_of_pericenter_deg));
299    kv("MEAN_ANOMALY", &fmt_num(omm.mean_anomaly_deg));
300    kv("EPHEMERIS_TYPE", &omm.ephemeris_type.to_string());
301    kv("CLASSIFICATION_TYPE", &omm.classification_type);
302    kv("NORAD_CAT_ID", &omm.norad_cat_id.to_string());
303    kv("ELEMENT_SET_NO", &omm.element_set_no.to_string());
304    kv("REV_AT_EPOCH", &omm.rev_at_epoch.to_string());
305    kv("BSTAR", &fmt_num(omm.bstar));
306    kv("MEAN_MOTION_DOT", &fmt_num(omm.mean_motion_dot));
307    kv("MEAN_MOTION_DDOT", &fmt_num(omm.mean_motion_ddot));
308    out
309}
310
311// ── XML ──────────────────────────────────────────────────────────────
312
313/// Parse a CCSDS OMM in XML encoding into an [`Omm`].
314///
315/// Uses the `roxmltree` DOM reader (which handles the `<?xml?>` declaration,
316/// namespaces, comments, and entity decoding) and then reads the known CCSDS
317/// leaf elements by name into the shared `(key, value)` field set, exactly the
318/// representation the KVN tokenizer produces, so both encodings flow through the
319/// single field mapping. The format-set version is taken from the `version`
320/// attribute on `<omm>`.
321pub fn parse_xml(text: &str) -> Result<Omm, OmmError> {
322    let doc = Document::parse(text).map_err(|e| OmmError::Field(format!("malformed XML: {e}")))?;
323    let mut fields: Vec<(String, String)> = Vec::new();
324
325    if let Some(omm_el) = doc
326        .descendants()
327        .find(|n| n.is_element() && n.tag_name().name() == "omm")
328    {
329        if let Some(version) = omm_el.attribute("version") {
330            fields.push(("CCSDS_OMM_VERS".to_string(), version.trim().to_string()));
331        }
332    }
333
334    for node in doc.descendants().filter(roxmltree::Node::is_element) {
335        let name = node.tag_name().name();
336        if FIELD_TAGS.contains(&name) {
337            let value = node.text().unwrap_or("").trim().to_string();
338            fields.push((name.to_string(), value));
339        }
340    }
341
342    let map = crate::format::kvn::FieldMap::from_pairs(fields);
343    Omm::from_field_map(&map)
344}
345
346/// Encode an [`Omm`] as a CCSDS OMM XML message, following the CelesTrak/`ndm`
347/// document layout. Numeric values use their shortest round-tripping form and
348/// text values are XML-escaped, so parsing the output reproduces the same [`Omm`].
349pub fn encode_xml(omm: &Omm) -> String {
350    fn elem(name: &str, value: &str) -> String {
351        format!("<{name}>{value}</{name}>")
352    }
353    let opt = |value: &Option<String>| xml::escape_opt(value);
354
355    let mut s = String::new();
356    s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
357    s.push_str("<ndm>\n");
358    let _ = writeln!(
359        s,
360        "<omm id=\"CCSDS_OMM_VERS\" version=\"{}\">",
361        xml::escape(&omm.ccsds_omm_vers)
362    );
363
364    s.push_str("<header>");
365    s.push_str(&elem("CREATION_DATE", &opt(&omm.creation_date)));
366    s.push_str(&elem("ORIGINATOR", &opt(&omm.originator)));
367    s.push_str("</header>\n");
368
369    s.push_str("<body><segment>\n<metadata>");
370    s.push_str(&elem("OBJECT_NAME", &opt(&omm.object_name)));
371    s.push_str(&elem("OBJECT_ID", &opt(&omm.object_id)));
372    s.push_str(&elem("CENTER_NAME", &opt(&omm.center_name)));
373    s.push_str(&elem("REF_FRAME", &opt(&omm.ref_frame)));
374    s.push_str(&elem("TIME_SYSTEM", &opt(&omm.time_system)));
375    s.push_str(&elem("MEAN_ELEMENT_THEORY", &opt(&omm.mean_element_theory)));
376    s.push_str("</metadata>\n<data>\n<meanElements>");
377
378    s.push_str(&elem("EPOCH", &omm.epoch.to_iso8601()));
379    s.push_str(&elem("MEAN_MOTION", &fmt_num(omm.mean_motion)));
380    s.push_str(&elem("ECCENTRICITY", &fmt_num(omm.eccentricity)));
381    s.push_str(&elem("INCLINATION", &fmt_num(omm.inclination_deg)));
382    s.push_str(&elem("RA_OF_ASC_NODE", &fmt_num(omm.ra_of_asc_node_deg)));
383    s.push_str(&elem(
384        "ARG_OF_PERICENTER",
385        &fmt_num(omm.arg_of_pericenter_deg),
386    ));
387    s.push_str(&elem("MEAN_ANOMALY", &fmt_num(omm.mean_anomaly_deg)));
388    s.push_str("</meanElements>\n<tleParameters>");
389
390    s.push_str(&elem("EPHEMERIS_TYPE", &omm.ephemeris_type.to_string()));
391    s.push_str(&elem(
392        "CLASSIFICATION_TYPE",
393        &xml::escape(&omm.classification_type),
394    ));
395    s.push_str(&elem("NORAD_CAT_ID", &omm.norad_cat_id.to_string()));
396    s.push_str(&elem("ELEMENT_SET_NO", &omm.element_set_no.to_string()));
397    s.push_str(&elem("REV_AT_EPOCH", &omm.rev_at_epoch.to_string()));
398    s.push_str(&elem("BSTAR", &fmt_num(omm.bstar)));
399    s.push_str(&elem("MEAN_MOTION_DOT", &fmt_num(omm.mean_motion_dot)));
400    s.push_str(&elem("MEAN_MOTION_DDOT", &fmt_num(omm.mean_motion_ddot)));
401    s.push_str("</tleParameters>\n</data>\n</segment></body>\n</omm>\n</ndm>\n");
402    s
403}
404
405// ── JSON ─────────────────────────────────────────────────────────────
406
407/// Parse a CCSDS/CelesTrak OMM in JSON encoding into an [`Omm`].
408///
409/// Accepts either a single object or an array of objects (CelesTrak GP queries
410/// return an array); the first record is taken. Each member is mapped onto the
411/// shared `(key, value)` field set - numbers stringified, strings taken verbatim
412/// (so the Space-Track quirk of quoting numeric values is handled) - then flows
413/// through the single field mapping. Requires the `json` feature.
414pub fn parse_json(text: &str) -> Result<Omm, OmmError> {
415    use serde_json::Value;
416
417    let value: Value =
418        serde_json::from_str(text).map_err(|e| OmmError::Field(format!("malformed JSON: {e}")))?;
419    let object = match &value {
420        Value::Array(items) => items
421            .first()
422            .ok_or_else(|| OmmError::Field("empty JSON array".to_string()))?,
423        Value::Object(_) => &value,
424        _ => {
425            return Err(OmmError::Field(
426                "expected a JSON object or array".to_string(),
427            ))
428        }
429    };
430    omm_from_json_value(object)
431}
432
433/// Map a single JSON OMM object onto the shared `(key, value)` field set and
434/// parse it. Used by both [`parse_json`] and [`parse_json_array`].
435fn omm_from_json_value(object: &serde_json::Value) -> Result<Omm, OmmError> {
436    let map = object
437        .as_object()
438        .ok_or_else(|| OmmError::Field("expected a JSON object".to_string()))?;
439    let fields: Vec<(String, String)> = map
440        .iter()
441        .map(|(key, value)| (key.clone(), json_scalar_to_string(value)))
442        .collect();
443    let map = crate::format::kvn::FieldMap::from_pairs(fields);
444    Omm::from_field_map(&map)
445}
446
447/// The result of parsing a CelesTrak OMM JSON array: every OMM that parsed,
448/// plus a count of array elements that were skipped.
449#[derive(Debug, Clone, PartialEq)]
450pub struct OmmArray {
451    /// The successfully parsed OMMs, in array order.
452    pub omms: Vec<Omm>,
453    /// How many array elements were skipped because they were not a parseable
454    /// OMM object (a non-object element, or an object that failed field
455    /// validation). Mirrors [`crate::astro::sgp4::TleFile::skipped`]: lets
456    /// callers tell an empty array (`omms` empty, `skipped == 0`) apart from one
457    /// whose every element was malformed (`skipped > 0`) without aborting the
458    /// whole parse on one bad entry. No fabricated OMM is emitted in their place.
459    pub skipped: usize,
460}
461
462/// Parse a CelesTrak OMM JSON array into every contained [`Omm`].
463///
464/// CelesTrak GP queries return a JSON array of OMM objects; this reads all of
465/// them (a lone object is accepted as a one-element array) through the same
466/// field mapping [`parse_json`] uses. An individual array element that is not a
467/// valid OMM object is skipped and counted in [`OmmArray::skipped`] rather than
468/// aborting the whole array. A malformed top-level document (not valid JSON, or
469/// neither an object nor an array) is still an error. Requires the `json`
470/// feature.
471pub fn parse_json_array(text: &str) -> Result<OmmArray, OmmError> {
472    use serde_json::Value;
473
474    let value: Value =
475        serde_json::from_str(text).map_err(|e| OmmError::Field(format!("malformed JSON: {e}")))?;
476    let items: &[Value] = match &value {
477        Value::Array(items) => items.as_slice(),
478        Value::Object(_) => std::slice::from_ref(&value),
479        _ => {
480            return Err(OmmError::Field(
481                "expected a JSON object or array".to_string(),
482            ))
483        }
484    };
485
486    let mut omms = Vec::with_capacity(items.len());
487    let mut skipped = 0usize;
488    for object in items {
489        match omm_from_json_value(object) {
490            Ok(omm) => omms.push(omm),
491            Err(_) => skipped += 1,
492        }
493    }
494    Ok(OmmArray { omms, skipped })
495}
496
497/// Encode an [`Omm`] as a CCSDS/CelesTrak OMM JSON object.
498///
499/// Numeric element values are emitted as JSON numbers (round-tripping the exact
500/// `f64`), strings as JSON strings, and the epoch as an ISO-8601 string, so
501/// parsing the output reproduces the same [`Omm`]. Requires the `json` feature.
502pub fn encode_json(omm: &Omm) -> String {
503    use serde_json::{Map, Number, Value};
504
505    let num = |x: f64| Number::from_f64(x).map_or(Value::Null, Value::Number);
506    let opt = |value: &Option<String>| value.clone().map_or(Value::Null, Value::String);
507
508    let mut map = Map::new();
509    map.insert(
510        "CCSDS_OMM_VERS".into(),
511        Value::String(omm.ccsds_omm_vers.clone()),
512    );
513    map.insert("CREATION_DATE".into(), opt(&omm.creation_date));
514    map.insert("ORIGINATOR".into(), opt(&omm.originator));
515    map.insert("OBJECT_NAME".into(), opt(&omm.object_name));
516    map.insert("OBJECT_ID".into(), opt(&omm.object_id));
517    map.insert("CENTER_NAME".into(), opt(&omm.center_name));
518    map.insert("REF_FRAME".into(), opt(&omm.ref_frame));
519    map.insert("TIME_SYSTEM".into(), opt(&omm.time_system));
520    map.insert("MEAN_ELEMENT_THEORY".into(), opt(&omm.mean_element_theory));
521    map.insert("EPOCH".into(), Value::String(omm.epoch.to_iso8601()));
522    map.insert("MEAN_MOTION".into(), num(omm.mean_motion));
523    map.insert("ECCENTRICITY".into(), num(omm.eccentricity));
524    map.insert("INCLINATION".into(), num(omm.inclination_deg));
525    map.insert("RA_OF_ASC_NODE".into(), num(omm.ra_of_asc_node_deg));
526    map.insert("ARG_OF_PERICENTER".into(), num(omm.arg_of_pericenter_deg));
527    map.insert("MEAN_ANOMALY".into(), num(omm.mean_anomaly_deg));
528    map.insert(
529        "EPHEMERIS_TYPE".into(),
530        Value::Number(omm.ephemeris_type.into()),
531    );
532    map.insert(
533        "CLASSIFICATION_TYPE".into(),
534        Value::String(omm.classification_type.clone()),
535    );
536    map.insert(
537        "NORAD_CAT_ID".into(),
538        Value::Number(omm.norad_cat_id.into()),
539    );
540    map.insert(
541        "ELEMENT_SET_NO".into(),
542        Value::Number(omm.element_set_no.into()),
543    );
544    map.insert(
545        "REV_AT_EPOCH".into(),
546        Value::Number(omm.rev_at_epoch.into()),
547    );
548    map.insert("BSTAR".into(), num(omm.bstar));
549    map.insert("MEAN_MOTION_DOT".into(), num(omm.mean_motion_dot));
550    map.insert("MEAN_MOTION_DDOT".into(), num(omm.mean_motion_ddot));
551    Value::Object(map).to_string()
552}
553
554/// Render a JSON scalar as the string the shared field mapping consumes. Numbers
555/// use their canonical decimal form; strings pass through; null becomes empty.
556fn json_scalar_to_string(value: &serde_json::Value) -> String {
557    use serde_json::Value;
558    match value {
559        Value::String(s) => s.clone(),
560        Value::Number(n) => n.to_string(),
561        Value::Bool(b) => b.to_string(),
562        Value::Null => String::new(),
563        other => other.to_string(),
564    }
565}
566
567// ── Encoding auto-detect ─────────────────────────────────────────────
568
569/// Parse an OMM in any supported encoding, detecting it from the leading
570/// non-whitespace character: `<` is XML, `{` or `[` is JSON, anything else is
571/// KVN. JSON requires the `json` feature; without it a JSON document returns an
572/// error rather than being misread.
573pub fn parse(text: &str) -> Result<Omm, OmmError> {
574    match text.trim_start().chars().next() {
575        Some('<') => parse_xml(text),
576        Some('{') | Some('[') => parse_json_detected(text),
577        _ => parse_kvn(text),
578    }
579}
580
581/// Parse a single CCSDS `EPOCH` string field to the canonical [`OmmEpoch`].
582///
583/// The accepted form is `YYYY-MM-DDThh:mm:ss[.f...][Z]` with up to 15
584/// fractional-second digits (whole microseconds plus a femtosecond remainder),
585/// interpreted under the UTC-like civil-second policy (the OMM default when no
586/// `TIME_SYSTEM` is declared, matching how a CelesTrak GP `EPOCH` is read).
587/// This is the single public entry point a thin binding (for example the
588/// Elixir constellation NIF) delegates to instead of hand-rolling the split;
589/// it wraps the same shared `NdmEpoch` parser the full OMM decode uses, so it
590/// produces byte-identical components.
591pub fn parse_epoch(text: &str) -> Result<OmmEpoch, OmmError> {
592    OmmEpoch::parse(text, validate::CivilSecondPolicy::UtcLike)
593}
594
595fn parse_json_detected(text: &str) -> Result<Omm, OmmError> {
596    parse_json(text)
597}
598
599// ── Field mapping (shared by every encoding) ─────────────────────────
600
601impl Omm {
602    /// Build an [`Omm`] from the decoded CCSDS field set: a list of
603    /// `(key, value)` string pairs as produced by any of the encodings. This is
604    /// the single place the CCSDS field names map onto the canonical container.
605    pub(crate) fn from_field_map(map: &crate::format::kvn::FieldMap) -> Result<Omm, OmmError> {
606        let get = |key: &str| map.get(key);
607
608        let time_system = xml_text(get("TIME_SYSTEM"), "TIME_SYSTEM")?;
609        let epoch = OmmEpoch::parse(
610            get("EPOCH").ok_or(OmmError::MissingField("EPOCH"))?,
611            omm_civil_second_policy(time_system.as_deref()),
612        )?;
613
614        Ok(Omm {
615            ccsds_omm_vers: xml_text_or_default(get("CCSDS_OMM_VERS"), "CCSDS_OMM_VERS", "2.0")?,
616            creation_date: xml_text(get("CREATION_DATE"), "CREATION_DATE")?,
617            originator: xml_text(get("ORIGINATOR"), "ORIGINATOR")?,
618            object_name: xml_text(get("OBJECT_NAME"), "OBJECT_NAME")?,
619            object_id: xml_text(get("OBJECT_ID"), "OBJECT_ID")?,
620            center_name: xml_text(get("CENTER_NAME"), "CENTER_NAME")?,
621            ref_frame: xml_text(get("REF_FRAME"), "REF_FRAME")?,
622            time_system,
623            mean_element_theory: xml_text(get("MEAN_ELEMENT_THEORY"), "MEAN_ELEMENT_THEORY")?,
624            epoch,
625            mean_motion: req_num(get("MEAN_MOTION"), "MEAN_MOTION")?,
626            eccentricity: req_num(get("ECCENTRICITY"), "ECCENTRICITY")?,
627            inclination_deg: req_num(get("INCLINATION"), "INCLINATION")?,
628            ra_of_asc_node_deg: req_num(get("RA_OF_ASC_NODE"), "RA_OF_ASC_NODE")?,
629            arg_of_pericenter_deg: req_num(get("ARG_OF_PERICENTER"), "ARG_OF_PERICENTER")?,
630            mean_anomaly_deg: req_num(get("MEAN_ANOMALY"), "MEAN_ANOMALY")?,
631            ephemeris_type: opt_int(get("EPHEMERIS_TYPE"), "EPHEMERIS_TYPE")?.unwrap_or(0),
632            classification_type: xml_text_or_default(
633                get("CLASSIFICATION_TYPE"),
634                "CLASSIFICATION_TYPE",
635                "U",
636            )?,
637            norad_cat_id: req_int(get("NORAD_CAT_ID"), "NORAD_CAT_ID")?,
638            element_set_no: opt_int(get("ELEMENT_SET_NO"), "ELEMENT_SET_NO")?.unwrap_or(999),
639            rev_at_epoch: opt_int(get("REV_AT_EPOCH"), "REV_AT_EPOCH")?.unwrap_or(0),
640            bstar: req_num(get("BSTAR"), "BSTAR")?,
641            mean_motion_dot: req_num(get("MEAN_MOTION_DOT"), "MEAN_MOTION_DOT")?,
642            mean_motion_ddot: req_num(get("MEAN_MOTION_DDOT"), "MEAN_MOTION_DDOT")?,
643            exact_sgp4_epoch: None,
644            quantize_tle_derived_fields: true,
645        })
646    }
647}
648
649fn xml_text(value: Option<&str>, field: &'static str) -> Result<Option<String>, OmmError> {
650    value
651        .map(|value| xml_text_value(value, field).map(str::to_string))
652        .transpose()
653}
654
655fn xml_text_or_default(
656    value: Option<&str>,
657    field: &'static str,
658    default: &'static str,
659) -> Result<String, OmmError> {
660    xml_text_value(value.unwrap_or(default), field).map(str::to_string)
661}
662
663fn xml_text_value<'a>(value: &'a str, field: &'static str) -> Result<&'a str, OmmError> {
664    if let Some(ch) = xml::first_illegal_xml_1_0_char(value) {
665        return Err(OmmError::Field(format!(
666            "field {field} contains XML-illegal character U+{:04X}",
667            ch as u32
668        )));
669    }
670    Ok(value)
671}
672
673// ── SGP4 bridge ──────────────────────────────────────────────────────
674
675impl Omm {
676    /// Convert the canonical OMM elements into the SGP4 [`ElementSet`] consumed
677    /// by [`Satellite::from_elements`].
678    ///
679    /// The epoch is converted directly from the OMM calendar timestamp into
680    /// SGP4's split Julian date, preserving years outside the TLE pivot range.
681    /// B\* and the second mean-motion derivative are quantized onto the TLE
682    /// assumed-decimal grid because those GP parameters originate in that field
683    /// format.
684    pub fn to_element_set(&self) -> Result<ElementSet, OmmError> {
685        validate_omm_bridge(self)?;
686        let bstar = if self.quantize_tle_derived_fields {
687            tle::assumed_decimal_quantize(self.bstar)
688        } else {
689            self.bstar
690        };
691        let mean_motion_double_dot = if self.quantize_tle_derived_fields {
692            tle::assumed_decimal_quantize(self.mean_motion_ddot)
693        } else {
694            self.mean_motion_ddot
695        };
696        Ok(ElementSet {
697            epoch: self
698                .exact_sgp4_epoch
699                .unwrap_or_else(|| self.epoch.sgp4_julian_date()),
700            bstar,
701            mean_motion_dot: self.mean_motion_dot,
702            mean_motion_double_dot,
703            eccentricity: self.eccentricity,
704            argument_of_perigee_deg: self.arg_of_pericenter_deg,
705            inclination_deg: self.inclination_deg,
706            mean_anomaly_deg: self.mean_anomaly_deg,
707            mean_motion_rev_per_day: self.mean_motion,
708            right_ascension_deg: self.ra_of_asc_node_deg,
709            catalog_number: self.norad_cat_id,
710        })
711    }
712}
713
714impl Satellite {
715    /// Build a propagation-ready [`Satellite`] from an [`Omm`].
716    ///
717    /// Bridges the OMM mean elements into the validated SGP4 element path via
718    /// [`Omm::to_element_set`].
719    pub fn from_omm(omm: &Omm) -> Result<Self, Sgp4Error> {
720        let elements = omm.to_element_set().map_err(map_omm_bridge_to_sgp4)?;
721        Self::from_elements(&elements)
722    }
723}
724
725fn validate_omm_bridge(omm: &Omm) -> Result<(), OmmError> {
726    if omm.epoch.microsecond >= 1_000_000 {
727        return Err(OmmError::InvalidField {
728            field: "epoch.microsecond",
729            kind: OmmInputErrorKind::OutOfRange,
730        });
731    }
732    if omm.epoch.femtosecond >= 1_000_000_000 {
733        return Err(OmmError::InvalidField {
734            field: "epoch.femtosecond",
735            kind: OmmInputErrorKind::OutOfRange,
736        });
737    }
738    validate::finite_positive(omm.mean_motion, "mean_motion").map_err(map_omm_field_error)?;
739    validate::finite_in_range_exclusive_upper(omm.eccentricity, 0.0, 1.0, "eccentricity")
740        .map_err(map_omm_field_error)?;
741    validate::finite(omm.inclination_deg, "inclination_deg").map_err(map_omm_field_error)?;
742    validate::finite(omm.ra_of_asc_node_deg, "ra_of_asc_node_deg").map_err(map_omm_field_error)?;
743    validate::finite(omm.arg_of_pericenter_deg, "arg_of_pericenter_deg")
744        .map_err(map_omm_field_error)?;
745    validate::finite(omm.mean_anomaly_deg, "mean_anomaly_deg").map_err(map_omm_field_error)?;
746    validate::finite(omm.bstar, "bstar").map_err(map_omm_field_error)?;
747    validate::finite(omm.mean_motion_dot, "mean_motion_dot").map_err(map_omm_field_error)?;
748    validate::finite(omm.mean_motion_ddot, "mean_motion_ddot").map_err(map_omm_field_error)?;
749    Ok(())
750}
751
752fn map_omm_bridge_to_sgp4(error: OmmError) -> Sgp4Error {
753    match error {
754        OmmError::InvalidField { field, kind } => Sgp4Error::InvalidInput {
755            field,
756            kind: match kind {
757                OmmInputErrorKind::NonFinite => Sgp4InputErrorKind::NonFinite,
758                OmmInputErrorKind::NotPositive => Sgp4InputErrorKind::NotPositive,
759                OmmInputErrorKind::Negative => Sgp4InputErrorKind::Negative,
760                OmmInputErrorKind::OutOfRange => Sgp4InputErrorKind::OutOfRange,
761                OmmInputErrorKind::Missing => Sgp4InputErrorKind::Missing,
762                OmmInputErrorKind::FloatParse => Sgp4InputErrorKind::FloatParse,
763                OmmInputErrorKind::IntParse => Sgp4InputErrorKind::IntParse,
764                OmmInputErrorKind::InvalidCivilDate => Sgp4InputErrorKind::InvalidCivilDate,
765                OmmInputErrorKind::InvalidCivilTime => Sgp4InputErrorKind::InvalidCivilTime,
766            },
767        },
768        other => Sgp4Error::InvalidTle(other.to_string()),
769    }
770}
771
772// ── Epoch ────────────────────────────────────────────────────────────
773
774impl OmmEpoch {
775    /// Parse a CCSDS `EPOCH` value (`YYYY-MM-DDThh:mm:ss[.f...][Z]`, UTC) by
776    /// delegating to the shared NDM epoch parser.
777    fn parse(text: &str, second_policy: validate::CivilSecondPolicy) -> Result<OmmEpoch, OmmError> {
778        let e = crate::astro::ndm::NdmEpoch::parse(text, second_policy)
779            .map_err(|err| map_omm_epoch_field_error(err, text.trim()))?;
780        Ok(OmmEpoch {
781            year: e.year,
782            month: e.month,
783            day: e.day,
784            hour: e.hour,
785            minute: e.minute,
786            second: e.second,
787            microsecond: e.microsecond,
788            femtosecond: e.femtosecond,
789        })
790    }
791
792    /// Convert directly to the SGP4 split Julian date from the full OMM
793    /// calendar timestamp.
794    fn sgp4_julian_date(&self) -> sgp4::JulianDate {
795        sgp4::sgp4_julian_date_from_calendar(
796            self.year,
797            self.month as i32,
798            self.day as i32,
799            self.hour as i32,
800            self.minute as i32,
801            self.second as f64
802                + self.microsecond as f64 / 1_000_000.0
803                + self.femtosecond as f64 / 1_000_000_000_000_000.0,
804        )
805    }
806
807    pub(crate) fn from_sgp4_julian_date(epoch: JulianDate) -> Self {
808        let (mut jd_midnight, mut day_fraction) = if (epoch.0.fract().abs() - 0.5).abs() < 1.0e-9 {
809            (epoch.0, epoch.1)
810        } else if epoch.1 >= 0.5 {
811            (epoch.0 + 0.5, epoch.1 - 0.5)
812        } else {
813            (epoch.0 - 0.5, epoch.1 + 0.5)
814        };
815        let day_carry = day_fraction.floor();
816        jd_midnight += day_carry;
817        day_fraction -= day_carry;
818        let (year, month, day, hour, minute, second) =
819            crate::astro::time::civil::civil_from_split_julian_date(jd_midnight, day_fraction);
820        let whole_second = second.floor();
821        let subsecond = second - whole_second;
822        let mut femtoseconds = (subsecond * FEMTOSECONDS_PER_SECOND as f64).round() as i128;
823        let mut second = whole_second as u32;
824        if femtoseconds == FEMTOSECONDS_PER_SECOND {
825            second += 1;
826            femtoseconds = 0;
827        }
828        OmmEpoch {
829            year: year as i32,
830            month: month as u32,
831            day: day as u32,
832            hour: hour as u32,
833            minute: minute as u32,
834            second,
835            microsecond: (femtoseconds / FEMTOSECONDS_PER_MICROSECOND) as u32,
836            femtosecond: (femtoseconds % FEMTOSECONDS_PER_MICROSECOND) as u32,
837        }
838    }
839
840    /// Format as a CCSDS `EPOCH` string via the shared NDM epoch encoder:
841    /// six fractional digits, extended to 15 only when a sub-microsecond
842    /// remainder is present.
843    fn to_iso8601(&self) -> String {
844        crate::astro::ndm::NdmEpoch {
845            year: self.year,
846            month: self.month,
847            day: self.day,
848            hour: self.hour,
849            minute: self.minute,
850            second: self.second,
851            microsecond: self.microsecond,
852            femtosecond: self.femtosecond,
853        }
854        .to_iso8601()
855    }
856}
857
858const FEMTOSECONDS_PER_SECOND: i128 = 1_000_000_000_000_000;
859const FEMTOSECONDS_PER_MICROSECOND: i128 = 1_000_000_000;
860
861fn is_zero_u32(value: &u32) -> bool {
862    *value == 0
863}
864
865fn default_quantize_tle_derived_fields() -> bool {
866    true
867}
868
869// ── Numeric helpers ──────────────────────────────────────────────────
870
871fn omm_civil_second_policy(time_system: Option<&str>) -> validate::CivilSecondPolicy {
872    let Some(label) = time_system.map(str::trim).filter(|label| !label.is_empty()) else {
873        return validate::CivilSecondPolicy::UtcLike;
874    };
875    if label.eq_ignore_ascii_case("UTC")
876        || label.eq_ignore_ascii_case("GLO")
877        || label.eq_ignore_ascii_case("GLONASS")
878    {
879        validate::CivilSecondPolicy::UtcLike
880    } else {
881        validate::CivilSecondPolicy::Continuous
882    }
883}
884
885fn req_num(value: Option<&str>, field: &'static str) -> Result<f64, OmmError> {
886    let value = value.ok_or(OmmError::MissingField(field))?;
887    parse_num(value, field)
888}
889
890fn parse_num(value: &str, field: &'static str) -> Result<f64, OmmError> {
891    validate::strict_f64(value, field).map_err(map_omm_field_error)
892}
893
894fn req_int<T>(value: Option<&str>, field: &'static str) -> Result<T, OmmError>
895where
896    T: std::str::FromStr,
897{
898    let value = value.ok_or(OmmError::MissingField(field))?;
899    parse_int(value, field)
900}
901
902fn opt_int<T>(value: Option<&str>, field: &'static str) -> Result<Option<T>, OmmError>
903where
904    T: std::str::FromStr,
905{
906    value.map(|v| parse_int(v, field)).transpose()
907}
908
909fn parse_int<T>(value: &str, field: &'static str) -> Result<T, OmmError>
910where
911    T: std::str::FromStr,
912{
913    validate::strict_int::<T>(value, field).map_err(map_omm_field_error)
914}
915
916fn map_omm_field_error(error: validate::FieldError) -> OmmError {
917    OmmError::InvalidField {
918        field: error.field(),
919        kind: OmmInputErrorKind::from(&error),
920    }
921}
922
923fn map_omm_epoch_field_error(error: validate::FieldError, full: &str) -> OmmError {
924    match error {
925        validate::FieldError::Missing { .. }
926        | validate::FieldError::FloatParse { .. }
927        | validate::FieldError::IntParse { .. } => {
928            OmmError::Epoch(format!("invalid seconds in {full:?}"))
929        }
930        _ => map_omm_field_error(error),
931    }
932}
933
934/// Shortest decimal form of a value that round-trips back to the same `f64`.
935fn fmt_num(value: f64) -> String {
936    format!("{value}")
937}
938
939#[cfg(all(test, sidereon_repo_tests))]
940mod tests {
941    use super::*;
942
943    const ISS_KVN: &str = include_str!("../../tests/fixtures/omm/25544.kvn");
944    const ISS_XML: &str = include_str!("../../tests/fixtures/omm/25544.xml");
945
946    /// Reduce an OMM to its canonical orbital + catalog content (the fields the
947    /// Elixir `Sidereon.Elements` struct carries), blanking the free-text header
948    /// metadata that CelesTrak emits inconsistently across encodings: it labels
949    /// the element theory `SGP/SGP4` in KVN but `SGP4` in XML/JSON, and its JSON
950    /// omits `CENTER_NAME`/`REF_FRAME`/`TIME_SYSTEM` entirely. Cross-encoding
951    /// identity is asserted on this canonical content, which must match exactly.
952    fn canonical(omm: &Omm) -> Omm {
953        Omm {
954            ccsds_omm_vers: String::new(),
955            creation_date: None,
956            originator: None,
957            center_name: None,
958            ref_frame: None,
959            time_system: None,
960            mean_element_theory: None,
961            ..omm.clone()
962        }
963    }
964
965    fn kvn_with_field(field: &str, value: &str) -> String {
966        kvn_with_fields(&[(field, value)])
967    }
968
969    fn kvn_with_fields(fields: &[(&str, &str)]) -> String {
970        ISS_KVN
971            .lines()
972            .map(|line| match line.split_once('=') {
973                Some((key, _)) => fields
974                    .iter()
975                    .find(|(field, _)| key.trim() == *field)
976                    .map_or_else(
977                        || line.to_string(),
978                        |(field, value)| format!("{field} = {value}"),
979                    ),
980                _ => line.to_string(),
981            })
982            .collect::<Vec<_>>()
983            .join("\n")
984    }
985
986    fn kvn_without_field(field: &str) -> String {
987        ISS_KVN
988            .lines()
989            .filter(|line| match line.split_once('=') {
990                Some((key, _)) => key.trim() != field,
991                None => true,
992            })
993            .collect::<Vec<_>>()
994            .join("\n")
995    }
996
997    #[test]
998    fn parses_iss_kvn_fields() {
999        let omm = parse_kvn(ISS_KVN).unwrap();
1000        assert_eq!(omm.ccsds_omm_vers, "2.0");
1001        assert_eq!(omm.object_name.as_deref(), Some("ISS (ZARYA)"));
1002        assert_eq!(omm.object_id.as_deref(), Some("1998-067A"));
1003        assert_eq!(omm.norad_cat_id, 25544);
1004        assert_eq!(omm.mean_motion, 15.49273435);
1005        assert_eq!(omm.eccentricity, 0.0004737);
1006        assert_eq!(omm.inclination_deg, 51.6332);
1007        assert_eq!(omm.bstar, 0.00017172);
1008        assert_eq!(omm.mean_motion_dot, 9.113e-5);
1009        assert_eq!(omm.mean_motion_ddot, 0.0);
1010        assert_eq!(
1011            omm.epoch,
1012            OmmEpoch {
1013                year: 2026,
1014                month: 6,
1015                day: 17,
1016                hour: 4,
1017                minute: 32,
1018                second: 52,
1019                microsecond: 99296,
1020                femtosecond: 0,
1021            }
1022        );
1023    }
1024
1025    #[test]
1026    fn parse_kvn_requires_drag_terms() {
1027        for field in ["BSTAR", "MEAN_MOTION_DOT", "MEAN_MOTION_DDOT"] {
1028            assert_eq!(
1029                parse_kvn(&kvn_without_field(field)),
1030                Err(OmmError::MissingField(field))
1031            );
1032        }
1033    }
1034
1035    #[test]
1036    fn parse_kvn_rejects_non_finite_drag_terms() {
1037        for field in ["BSTAR", "MEAN_MOTION_DOT", "MEAN_MOTION_DDOT"] {
1038            assert_eq!(
1039                parse_kvn(&kvn_with_field(field, "NaN")),
1040                Err(OmmError::InvalidField {
1041                    field,
1042                    kind: OmmInputErrorKind::NonFinite,
1043                })
1044            );
1045        }
1046    }
1047
1048    #[test]
1049    fn parse_kvn_rejects_negative_norad_catalog_id() {
1050        assert_eq!(
1051            parse_kvn(&kvn_with_field("NORAD_CAT_ID", "-1")),
1052            Err(OmmError::InvalidField {
1053                field: "NORAD_CAT_ID",
1054                kind: OmmInputErrorKind::IntParse,
1055            })
1056        );
1057    }
1058
1059    #[test]
1060    fn parse_kvn_rejects_oversized_norad_catalog_id() {
1061        assert_eq!(
1062            parse_kvn(&kvn_with_field("NORAD_CAT_ID", "4294967296")),
1063            Err(OmmError::InvalidField {
1064                field: "NORAD_CAT_ID",
1065                kind: OmmInputErrorKind::IntParse,
1066            })
1067        );
1068    }
1069
1070    #[test]
1071    fn parse_kvn_rejects_invalid_civil_epoch() {
1072        assert_eq!(
1073            parse_kvn(&kvn_with_field("EPOCH", "2026-02-30T04:32:52.099296")),
1074            Err(OmmError::InvalidField {
1075                field: "civil datetime",
1076                kind: OmmInputErrorKind::InvalidCivilDate,
1077            })
1078        );
1079        assert_eq!(
1080            parse_kvn(&kvn_with_field("EPOCH", "2026-06-17T24:00:00.000000")),
1081            Err(OmmError::InvalidField {
1082                field: "civil datetime",
1083                kind: OmmInputErrorKind::InvalidCivilTime,
1084            })
1085        );
1086    }
1087
1088    #[test]
1089    fn parse_kvn_accepts_utc_leap_second_epoch() {
1090        let omm = parse_kvn(&kvn_with_field("EPOCH", "2016-12-31T23:59:60.000000Z"))
1091            .expect("OMM leap-second epoch");
1092        assert_eq!(
1093            omm.epoch,
1094            OmmEpoch {
1095                year: 2016,
1096                month: 12,
1097                day: 31,
1098                hour: 23,
1099                minute: 59,
1100                second: 60,
1101                microsecond: 0,
1102                femtosecond: 0,
1103            }
1104        );
1105    }
1106
1107    #[test]
1108    fn parse_kvn_rejects_gps_time_leap_second_epoch() {
1109        assert_eq!(
1110            parse_kvn(&kvn_with_fields(&[
1111                ("TIME_SYSTEM", "GPS"),
1112                ("EPOCH", "2016-12-31T23:59:60.000000Z"),
1113            ])),
1114            Err(OmmError::InvalidField {
1115                field: "civil datetime",
1116                kind: OmmInputErrorKind::InvalidCivilTime,
1117            })
1118        );
1119    }
1120
1121    #[test]
1122    fn parse_kvn_rejects_invalid_leap_second_range() {
1123        assert!(parse_kvn(&kvn_with_field("EPOCH", "2016-12-31T23:59:61.000000Z")).is_err());
1124        assert!(parse_kvn(&kvn_with_field("EPOCH", "2016-12-31T23:59:-1.000000Z")).is_err());
1125    }
1126
1127    #[test]
1128    fn parse_kvn_requires_fractional_epoch_digits() {
1129        let omm = parse_kvn(&kvn_with_field("EPOCH", "2026-06-17T04:32:52.500"))
1130            .expect("fractional epoch");
1131        assert_eq!(omm.epoch.microsecond, 500_000);
1132
1133        let omm = parse_kvn(&kvn_with_field("EPOCH", "2026-06-17T04:32:52.5Z"))
1134            .expect("fractional epoch with UTC suffix");
1135        assert_eq!(omm.epoch.microsecond, 500_000);
1136
1137        for epoch in [
1138            "2026-06-17T04:32:52.abc",
1139            "2026-06-17T04:32:52.abcZ",
1140            "2026-06-17T04:32:52.5x",
1141            "2026-06-17T04:32:52.5xZ",
1142            "2026-06-17T04:32:52.",
1143        ] {
1144            assert!(
1145                matches!(
1146                    parse_kvn(&kvn_with_field("EPOCH", epoch)),
1147                    Err(OmmError::Epoch(_))
1148                ),
1149                "{epoch} must be rejected"
1150            );
1151        }
1152    }
1153
1154    #[test]
1155    fn parse_kvn_preserves_sub_microsecond_epoch_seconds() {
1156        let omm = parse_kvn(&kvn_with_field("EPOCH", "2026-06-17T04:32:52.9999995"))
1157            .expect("fractional epoch");
1158        assert_eq!(
1159            omm.epoch,
1160            OmmEpoch {
1161                year: 2026,
1162                month: 6,
1163                day: 17,
1164                hour: 4,
1165                minute: 32,
1166                second: 52,
1167                microsecond: 999_999,
1168                femtosecond: 500_000_000,
1169            }
1170        );
1171        assert!(
1172            encode_kvn(&omm).contains("EPOCH = 2026-06-17T04:32:52.999999500000000"),
1173            "sub-microsecond epoch must encode with high fractional precision"
1174        );
1175    }
1176
1177    #[test]
1178    fn parse_kvn_preserves_continuous_time_sub_microsecond_epoch_near_day() {
1179        let omm = parse_kvn(&kvn_with_fields(&[
1180            ("TIME_SYSTEM", "GPS"),
1181            ("EPOCH", "2026-06-17T23:59:59.9999995"),
1182        ]))
1183        .expect("continuous-time fractional epoch");
1184        assert_eq!(
1185            omm.epoch,
1186            OmmEpoch {
1187                year: 2026,
1188                month: 6,
1189                day: 17,
1190                hour: 23,
1191                minute: 59,
1192                second: 59,
1193                microsecond: 999_999,
1194                femtosecond: 500_000_000,
1195            }
1196        );
1197    }
1198
1199    #[test]
1200    fn parse_kvn_preserves_continuous_time_sub_microsecond_epoch_near_year() {
1201        let ordinary = parse_kvn(&kvn_with_fields(&[
1202            ("TIME_SYSTEM", "GPS"),
1203            ("EPOCH", "2026-12-31T23:59:58.123456"),
1204        ]))
1205        .expect("ordinary continuous-time epoch");
1206        assert_eq!(
1207            ordinary.epoch,
1208            OmmEpoch {
1209                year: 2026,
1210                month: 12,
1211                day: 31,
1212                hour: 23,
1213                minute: 59,
1214                second: 58,
1215                microsecond: 123_456,
1216                femtosecond: 0,
1217            }
1218        );
1219        assert!(
1220            encode_kvn(&ordinary).contains("EPOCH = 2026-12-31T23:59:58.123456"),
1221            "ordinary epoch must encode unchanged"
1222        );
1223
1224        let carried = parse_kvn(&kvn_with_fields(&[
1225            ("TIME_SYSTEM", "GPS"),
1226            ("EPOCH", "2026-12-31T23:59:59.9999995"),
1227        ]))
1228        .expect("continuous-time fractional epoch near year boundary");
1229        assert_eq!(
1230            carried.epoch,
1231            OmmEpoch {
1232                year: 2026,
1233                month: 12,
1234                day: 31,
1235                hour: 23,
1236                minute: 59,
1237                second: 59,
1238                microsecond: 999_999,
1239                femtosecond: 500_000_000,
1240            }
1241        );
1242        assert!(
1243            encode_kvn(&carried).contains("EPOCH = 2026-12-31T23:59:59.999999500000000"),
1244            "sub-microsecond year-end epoch must encode unchanged"
1245        );
1246    }
1247
1248    #[test]
1249    fn kvn_round_trips_through_struct() {
1250        let omm = parse_kvn(ISS_KVN).unwrap();
1251        let reparsed = parse_kvn(&encode_kvn(&omm)).unwrap();
1252        assert_eq!(omm, reparsed);
1253    }
1254
1255    #[test]
1256    fn kvn_re_encodes_catalog_epoch_byte_faithfully() {
1257        // Parse -> encode must not balloon precision: a real microsecond-form
1258        // catalog epoch re-encodes as the exact source text, and the 15-digit
1259        // form appears only when sub-microsecond information is present.
1260        let source_epoch = ISS_KVN
1261            .lines()
1262            .find_map(|line| match line.split_once('=') {
1263                Some((key, value)) if key.trim() == "EPOCH" => Some(value.trim()),
1264                _ => None,
1265            })
1266            .expect("fixture EPOCH");
1267        let encoded = encode_kvn(&parse_kvn(ISS_KVN).unwrap());
1268        assert!(
1269            encoded.contains(&format!("EPOCH = {source_epoch}\n")),
1270            "catalog epoch {source_epoch} must re-encode byte-faithfully"
1271        );
1272        assert_eq!(source_epoch.len(), "2026-06-17T04:32:52.099296".len());
1273    }
1274
1275    #[test]
1276    fn kvn_round_trips_femtosecond_epoch_through_struct() {
1277        // IR-level parse(encode(ir)) == ir must include the femtosecond field.
1278        let omm = parse_kvn(&kvn_with_field("EPOCH", "2026-06-17T04:32:52.9999995")).unwrap();
1279        assert_eq!(omm.epoch.femtosecond, 500_000_000);
1280        let reparsed = parse_kvn(&encode_kvn(&omm)).unwrap();
1281        assert_eq!(omm, reparsed);
1282        assert_eq!(reparsed.epoch.femtosecond, 500_000_000);
1283    }
1284
1285    #[test]
1286    fn xml_matches_kvn_orbital_content() {
1287        let kvn = parse_kvn(ISS_KVN).unwrap();
1288        let xml = parse_xml(ISS_XML).unwrap();
1289        assert_eq!(canonical(&kvn), canonical(&xml));
1290    }
1291
1292    #[test]
1293    fn xml_round_trips_through_struct() {
1294        let omm = parse_xml(ISS_XML).unwrap();
1295        let reparsed = parse_xml(&encode_xml(&omm)).unwrap();
1296        assert_eq!(omm, reparsed);
1297    }
1298
1299    #[test]
1300    fn parse_kvn_rejects_xml_illegal_text_controls() {
1301        let err = parse_kvn(&kvn_with_field("MEAN_ELEMENT_THEORY", "SGP\u{0005}SGP4"))
1302            .expect_err("XML-illegal control characters must not enter OMM text fields");
1303        assert_eq!(
1304            err,
1305            OmmError::Field(
1306                "field MEAN_ELEMENT_THEORY contains XML-illegal character U+0005".to_string()
1307            )
1308        );
1309
1310        let omm = parse_kvn(&kvn_with_field("MEAN_ELEMENT_THEORY", "SGP\tSGP4"))
1311            .expect("XML-legal text control must remain valid");
1312        let reparsed = parse_xml(&encode_xml(&omm)).expect("encoded OMM must remain valid XML");
1313        assert_eq!(omm, reparsed);
1314    }
1315
1316    #[test]
1317    fn xml_round_trip_preserves_carriage_returns_in_text_values() {
1318        for value in ["SGP\rSGP4", "SGP\r\nSGP4"] {
1319            let mut omm = parse_kvn(ISS_KVN).expect("base OMM must parse");
1320            omm.mean_element_theory = Some(value.to_string());
1321            let encoded = encode_xml(&omm);
1322            assert!(encoded.contains("&#xD;"));
1323            assert!(!encoded.contains('\r'));
1324            let reparsed = parse_xml(&encoded).expect("encoded OMM must remain valid XML");
1325            assert_eq!(omm.mean_element_theory, reparsed.mean_element_theory);
1326            assert_eq!(omm, reparsed);
1327        }
1328    }
1329
1330    #[test]
1331    fn json_matches_kvn_orbital_content() {
1332        const ISS_JSON: &str = include_str!("../../tests/fixtures/omm/25544.json");
1333        let kvn = parse_kvn(ISS_KVN).unwrap();
1334        let json = parse_json(ISS_JSON).unwrap();
1335        assert_eq!(canonical(&kvn), canonical(&json));
1336    }
1337
1338    #[test]
1339    fn json_round_trips_through_struct() {
1340        const ISS_JSON: &str = include_str!("../../tests/fixtures/omm/25544.json");
1341        let omm = parse_json(ISS_JSON).unwrap();
1342        let reparsed = parse_json(&encode_json(&omm)).unwrap();
1343        assert_eq!(omm, reparsed);
1344    }
1345
1346    #[test]
1347    fn parse_auto_detects_encoding() {
1348        let from_kvn = parse(ISS_KVN).unwrap();
1349        let from_xml = parse(ISS_XML).unwrap();
1350        assert_eq!(parse_kvn(ISS_KVN).unwrap(), from_kvn);
1351        assert_eq!(parse_xml(ISS_XML).unwrap(), from_xml);
1352        assert_eq!(canonical(&from_kvn), canonical(&from_xml));
1353    }
1354
1355    #[test]
1356    fn parse_auto_detects_json_array() {
1357        const ISS_JSON: &str = include_str!("../../tests/fixtures/omm/25544.json");
1358        // CelesTrak JSON is a top-level array.
1359        assert_eq!(parse(ISS_JSON).unwrap(), parse_json(ISS_JSON).unwrap());
1360    }
1361
1362    #[test]
1363    fn parse_json_array_skips_malformed_objects_and_counts_them() {
1364        // A CelesTrak-shaped array with two good OMMs interleaved with a
1365        // non-object element and a malformed object (missing the required drag
1366        // terms). One bad object must not reject the whole array: the good
1367        // records survive and the skips are surfaced in `skipped`.
1368        let good = |norad: u32, id: &str| {
1369            format!(
1370                r#"{{"OBJECT_NAME":"SAT","OBJECT_ID":"{id}","EPOCH":"2026-06-17T04:32:52.099296","MEAN_MOTION":15.49273435,"ECCENTRICITY":0.0004737,"INCLINATION":51.6332,"RA_OF_ASC_NODE":300.0813,"ARG_OF_PERICENTER":195.1146,"MEAN_ANOMALY":164.9702,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":{norad},"ELEMENT_SET_NO":999,"REV_AT_EPOCH":57175,"BSTAR":0.00017172,"MEAN_MOTION_DOT":9.113e-5,"MEAN_MOTION_DDOT":0}}"#
1371            )
1372        };
1373        let text = format!(
1374            "[{}, \"not an object\", {{\"OBJECT_NAME\":\"BROKEN\",\"NORAD_CAT_ID\":99999}}, {}]",
1375            good(25544, "1998-067A"),
1376            good(25545, "1998-067B"),
1377        );
1378
1379        let result = parse_json_array(&text).expect("array with bad entries must still parse");
1380        assert_eq!(result.skipped, 2, "the string and the malformed object");
1381        let norads: Vec<u32> = result.omms.iter().map(|o| o.norad_cat_id).collect();
1382        assert_eq!(norads, vec![25544, 25545], "both good OMMs must survive");
1383    }
1384
1385    #[test]
1386    fn bstar_quantizes_onto_assumed_decimal_grid() {
1387        // OMM B* is the plain-decimal 0.00017172; the SGP4 element set must carry
1388        // the assumed-decimal value 0.17172e-3 the TLE actually feeds SGP4.
1389        let omm = parse_kvn(ISS_KVN).unwrap();
1390        let es = omm.to_element_set().expect("valid OMM bridge");
1391        assert_eq!(es.bstar, 0.17172 * 10.0_f64.powi(-3));
1392        assert_ne!(es.bstar, omm.bstar);
1393    }
1394
1395    #[test]
1396    fn to_element_set_rejects_invalid_bridge_fields() {
1397        let mut omm = parse_kvn(ISS_KVN).unwrap();
1398        omm.mean_motion = f64::NAN;
1399        assert_eq!(
1400            omm.to_element_set(),
1401            Err(OmmError::InvalidField {
1402                field: "mean_motion",
1403                kind: OmmInputErrorKind::NonFinite
1404            })
1405        );
1406
1407        let mut omm = parse_kvn(ISS_KVN).unwrap();
1408        omm.eccentricity = 1.0;
1409        assert_eq!(
1410            omm.to_element_set(),
1411            Err(OmmError::InvalidField {
1412                field: "eccentricity",
1413                kind: OmmInputErrorKind::OutOfRange
1414            })
1415        );
1416    }
1417
1418    #[test]
1419    fn from_omm_preserves_epoch_year_outside_tle_pivot_range() {
1420        let omm = parse_kvn(&kvn_with_field("EPOCH", "2057-01-01T00:00:00.000000"))
1421            .expect("future OMM epoch");
1422        let sat = Satellite::from_omm(&omm).expect("OMM with full-year epoch must initialize");
1423
1424        let epoch = sat.epoch_jd();
1425        let actual_jd = epoch.0 + epoch.1;
1426        let expected_jd = crate::astro::time::scales::julian_day_number(2057, 1, 1) as f64 - 0.5;
1427        let aliased_1957_jd =
1428            crate::astro::time::scales::julian_day_number(1957, 1, 1) as f64 - 0.5;
1429
1430        assert!(
1431            (actual_jd - expected_jd).abs() < 1.0e-9,
1432            "OMM epoch JD {actual_jd} must match the true 2057 epoch {expected_jd}",
1433        );
1434        assert!(
1435            (actual_jd - aliased_1957_jd).abs() > 36_000.0,
1436            "OMM epoch JD {actual_jd} must not alias to 1957 {aliased_1957_jd}",
1437        );
1438    }
1439
1440    #[test]
1441    fn from_omm_preserves_sub_microsecond_year_end_epoch_directly() {
1442        for (epoch, expected_year) in [
1443            ("2021-12-31T23:59:59.9999995", 2021),
1444            ("2020-12-31T23:59:59.9999995", 2020),
1445        ] {
1446            let omm = parse_kvn(&kvn_with_field("EPOCH", epoch)).expect("year-end OMM epoch");
1447            assert_eq!(omm.epoch.year, expected_year);
1448            assert_eq!(omm.epoch.month, 12);
1449            assert_eq!(omm.epoch.day, 31);
1450            assert_eq!(omm.epoch.hour, 23);
1451            assert_eq!(omm.epoch.minute, 59);
1452            assert_eq!(omm.epoch.second, 59);
1453            assert_eq!(omm.epoch.microsecond, 999_999);
1454            assert_eq!(omm.epoch.femtosecond, 500_000_000);
1455
1456            let sat =
1457                Satellite::from_omm(&omm).expect("sub-microsecond year-end OMM must initialize");
1458            let epoch_jd = sat.epoch_jd();
1459            let actual_jd = epoch_jd.0 + epoch_jd.1;
1460            let expected_jd =
1461                crate::astro::time::scales::julian_day_number(expected_year, 12, 31) as f64 - 0.5
1462                    + (86_399.999_999_5 / 86_400.0);
1463
1464            assert!(
1465                (actual_jd - expected_jd).abs() < 1.0e-9,
1466                "{epoch} converted to JD {actual_jd}, expected {expected_jd}",
1467            );
1468        }
1469    }
1470
1471    #[test]
1472    fn from_sgp4_julian_date_normalizes_split_fraction_carry() {
1473        let (jd_midnight, _) =
1474            crate::astro::time::civil::split_julian_date(2026, 12, 31, 0, 0, 0.0);
1475        let epoch = OmmEpoch::from_sgp4_julian_date(JulianDate(jd_midnight, 1.0));
1476
1477        assert_eq!(
1478            epoch,
1479            OmmEpoch {
1480                year: 2027,
1481                month: 1,
1482                day: 1,
1483                hour: 0,
1484                minute: 0,
1485                second: 0,
1486                microsecond: 0,
1487                femtosecond: 0,
1488            }
1489        );
1490    }
1491
1492    #[test]
1493    fn from_omm_rejects_invalid_sgp4_element_fields() {
1494        let mut omm = parse_kvn(ISS_KVN).unwrap();
1495        omm.mean_motion = f64::NAN;
1496        let err = Satellite::from_omm(&omm).expect_err("non-finite mean motion must error");
1497        assert_eq!(
1498            err,
1499            Sgp4Error::InvalidInput {
1500                field: "mean_motion",
1501                kind: crate::astro::sgp4::Sgp4InputErrorKind::NonFinite,
1502            }
1503        );
1504
1505        let mut omm = parse_kvn(ISS_KVN).unwrap();
1506        omm.eccentricity = 1.0;
1507        let err = Satellite::from_omm(&omm).expect_err("eccentricity >= 1 must error");
1508        assert_eq!(
1509            err,
1510            Sgp4Error::InvalidInput {
1511                field: "eccentricity",
1512                kind: crate::astro::sgp4::Sgp4InputErrorKind::OutOfRange,
1513            }
1514        );
1515    }
1516}