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