Skip to main content

sidereon_core/astro/sgp4/
mod.rs

1//! SGP4 satellite propagation with 0 ULP parity to the Vallado C++ reference
2//! implementation (v2020-07-13).
3//!
4//! This module is a faithful pure-Rust port of Vallado's SGP4 verified bit-for-bit
5//! against the canonical 33-satellite / 198-propagation-point Vallado verification
6//! suite. TLEs are curve-fit parameters generated by Vallado's SGP4; using a
7//! different propagator introduces errors. This module preserves the exact
8//! floating-point computation order of the C++ reference so output matches
9//! Python's `sgp4` C extension (which compiles the same source) bit-for-bit.
10//!
11//! ## Quick start
12//!
13//! ```
14//! use sidereon_core::astro::sgp4::{Satellite, MinutesSinceEpoch};
15//!
16//! let line1 = "1 25544U 98067A   18184.80969102  .00001614  00000-0  31745-4 0  9993";
17//! let line2 = "2 25544  51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
18//!
19//! let sat = Satellite::from_tle(line1, line2).unwrap();
20//! let pred = sat.propagate(MinutesSinceEpoch(0.0)).unwrap();
21//!
22//! // position in km, velocity in km/s, TEME frame
23//! let _ = pred.position;
24//! let _ = pred.velocity;
25//! ```
26
27#[allow(
28    dead_code,
29    unused_variables,
30    unused_assignments,
31    unused_mut,
32    non_snake_case,
33    non_camel_case_types,
34    clippy::approx_constant,
35    clippy::excessive_precision,
36    clippy::too_many_arguments,
37    clippy::needless_return,
38    clippy::assign_op_pattern,
39    clippy::manual_range_contains,
40    clippy::collapsible_if,
41    clippy::collapsible_else_if,
42    clippy::float_cmp,
43    clippy::needless_late_init,
44    clippy::field_reassign_with_default
45)]
46mod vallado;
47
48use crate::astro::tle;
49use crate::validate::{self, FieldError};
50use thiserror::Error;
51
52const MAX_VALLADO_SATNUM: u32 = 99_999;
53
54// ── Error ────────────────────────────────────────────────────────────
55
56/// Validation failure category for public SGP4 inputs.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Sgp4InputErrorKind {
59    /// A numeric input was NaN or infinite.
60    NonFinite,
61    /// A positive physical input was zero or negative.
62    NotPositive,
63    /// A non-negative physical input was negative.
64    Negative,
65    /// A numeric input was finite but outside the SGP4 domain.
66    OutOfRange,
67    /// A required input field was absent.
68    Missing,
69    /// A text field could not be parsed as a float.
70    FloatParse,
71    /// A text field could not be parsed as an integer.
72    IntParse,
73    /// A civil date field was out of range.
74    InvalidCivilDate,
75    /// A civil time field was out of range.
76    InvalidCivilTime,
77}
78
79impl core::fmt::Display for Sgp4InputErrorKind {
80    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
81        let label = match self {
82            Self::NonFinite => "not finite",
83            Self::NotPositive => "not positive",
84            Self::Negative => "negative",
85            Self::OutOfRange => "out of range",
86            Self::Missing => "missing",
87            Self::FloatParse => "invalid float",
88            Self::IntParse => "invalid integer",
89            Self::InvalidCivilDate => "invalid civil date",
90            Self::InvalidCivilTime => "invalid civil time",
91        };
92        f.write_str(label)
93    }
94}
95
96impl From<&FieldError> for Sgp4InputErrorKind {
97    fn from(error: &FieldError) -> Self {
98        match error {
99            FieldError::Missing { .. } => Self::Missing,
100            FieldError::NonFinite { .. } => Self::NonFinite,
101            FieldError::NotPositive { .. } => Self::NotPositive,
102            FieldError::Negative { .. } => Self::Negative,
103            FieldError::OutOfRange { .. } => Self::OutOfRange,
104            FieldError::FloatParse { .. } => Self::FloatParse,
105            FieldError::IntParse { .. } => Self::IntParse,
106            FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
107            FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
108        }
109    }
110}
111
112/// Error from SGP4 initialization or propagation.
113#[derive(Error, Debug, Clone, PartialEq)]
114pub enum Error {
115    /// A public SGP4 input was malformed, non-finite, or outside the model
116    /// domain. Boundary validation rejects this before the Vallado kernel runs.
117    #[error("invalid SGP4 input {field}: {kind}")]
118    InvalidInput {
119        /// The invalid input field.
120        field: &'static str,
121        /// The validation failure category.
122        kind: Sgp4InputErrorKind,
123    },
124    /// The Vallado step kernel returned a non-finite state vector.
125    #[error("SGP4 returned non-finite {field}")]
126    NonFiniteOutput {
127        /// The output vector that was non-finite.
128        field: &'static str,
129    },
130    /// TLE line has invalid format.
131    #[error("invalid TLE: {0}")]
132    InvalidTle(String),
133    /// SGP4 returned a non-zero error code.
134    ///
135    /// Codes: 1 = mean elements, 2 = mean motion, 3 = perturbed elements,
136    /// 4 = semi-latus rectum, 5 = epoch elements sub-orbital,
137    /// 6 = satellite decayed.
138    #[error("SGP4 error code {code}")]
139    Sgp4 { code: i32 },
140}
141
142const MAX_MINUTES_SINCE_EPOCH: f64 = 10_000_000.0;
143
144// ── Types ────────────────────────────────────────────────────────────
145
146/// Minutes since the TLE epoch. Newtype to prevent mixing with raw `f64`.
147#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
148pub struct MinutesSinceEpoch(pub f64);
149
150/// Propagation result in the TEME (True Equator, Mean Equinox) frame.
151#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
152pub struct Prediction {
153    /// Position in km, TEME frame.
154    pub position: [f64; 3],
155    /// Velocity in km/s, TEME frame.
156    pub velocity: [f64; 3],
157}
158
159/// Julian date split as `(whole, fraction)` for high-precision time input.
160///
161/// Skyfield convention: `whole = floor(JD)`, `fraction = remainder`.
162/// For example, 2018-07-04 00:00:00 UTC = `JulianDate(2458303.0, 0.5)`.
163#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
164pub struct JulianDate(pub f64, pub f64);
165
166pub(crate) fn sgp4_julian_date_from_calendar(
167    year: i32,
168    mon: i32,
169    day: i32,
170    hr: i32,
171    minute: i32,
172    sec: f64,
173) -> JulianDate {
174    let (jd, jdfrac) = vallado::jday_SGP4(year, mon, day, hr, minute, sec);
175    JulianDate(jd, jdfrac)
176}
177
178pub(crate) fn sgp4_julian_date_from_day_of_year(year: i32, days: f64) -> JulianDate {
179    let (mon, day, hr, minute, sec) = vallado::days2mdhms_SGP4(year, days);
180    let JulianDate(jd, jdfrac_raw) =
181        sgp4_julian_date_from_calendar(year, mon, day, hr, minute, sec);
182    let jdfrac = (jdfrac_raw * 100_000_000.0).round() / 100_000_000.0;
183    JulianDate(jd, jdfrac)
184}
185
186/// Vallado SGP4 operation mode. Controls which initialization branch
187/// `sgp4init` follows. The two modes produce subtly different results;
188/// the divergence grows with propagation time and can reach hundreds of
189/// millimeters over a few orbits at LEO.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
191pub enum OpsMode {
192    /// Improved formulation (Vallado `'i'`). Default for new work and
193    /// matches the default of Python's `sgp4` package. Recommended unless
194    /// you specifically need AFSPC operational parity.
195    #[default]
196    Improved,
197    /// AFSPC operational compatibility mode (Vallado `'a'`). Use this
198    /// when reproducing outputs from operational AFSPC systems or matching
199    /// reference values from older crates / catalogs that ran in AFSPC mode.
200    Afspc,
201}
202
203impl OpsMode {
204    fn as_char(self) -> char {
205        match self {
206            OpsMode::Improved => 'i',
207            OpsMode::Afspc => 'a',
208        }
209    }
210}
211
212/// Pre-parsed Vallado SGP4 element set.
213///
214/// Use this when the TLE has already been parsed externally (e.g. from an
215/// OMM message, JSON catalog, or another system) and you want to feed the
216/// element values directly into the SGP4 initializer instead of going through
217/// the TLE string parser.
218///
219/// Field units match the Vallado SGP4 reference inputs:
220/// angles in **degrees**, mean motion in **revolutions per day**, drag term
221/// in the dimensionless TLE convention.
222#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
223pub struct ElementSet {
224    /// Epoch as a split Julian date `(whole, fraction)`.
225    ///
226    /// This is format-agnostic and loss-free for the SGP4 initializer: TLE
227    /// conversion stores the exact split JD produced by Vallado's
228    /// `days2mdhms`/`jday` path with the legacy 8-decimal fraction rounding,
229    /// while OMM conversion stores the split JD from its full calendar
230    /// timestamp directly.
231    pub epoch: JulianDate,
232    /// SGP4 drag term (Vallado B\*). Dimensionless TLE convention.
233    pub bstar: f64,
234    /// First derivative of mean motion in rev/day². TLE "ndot".
235    pub mean_motion_dot: f64,
236    /// Second derivative of mean motion in rev/day³. TLE "nddot".
237    pub mean_motion_double_dot: f64,
238    /// Eccentricity, dimensionless, in [0, 1).
239    pub eccentricity: f64,
240    /// Argument of perigee, degrees.
241    pub argument_of_perigee_deg: f64,
242    /// Inclination, degrees.
243    pub inclination_deg: f64,
244    /// Mean anomaly, degrees.
245    pub mean_anomaly_deg: f64,
246    /// Mean motion, revolutions per day.
247    pub mean_motion_rev_per_day: f64,
248    /// Right ascension of ascending node (RAAN), degrees.
249    pub right_ascension_deg: f64,
250    /// Catalog (NORAD) number for this object. Used only for diagnostic
251    /// reporting inside SGP4 - propagation results do not depend on it.
252    /// Pass `0` if unknown.
253    pub catalog_number: u32,
254}
255
256// ── Satellite ────────────────────────────────────────────────────────
257
258/// A parsed TLE ready for propagation.
259///
260/// Holds the raw TLE lines plus the initialized SGP4 satellite record. The
261/// TLE is parsed and `sgp4init` is run exactly once during `from_tle`, so
262/// subsequent `propagate` calls just invoke the propagation kernel directly:
263/// fast, and crucially, with no precision loss from JD round-tripping.
264#[derive(Clone)]
265pub struct Satellite {
266    line1: String,
267    line2: String,
268    /// Source elements used to initialize the cached SGP4 record. Kept so
269    /// element-built satellites can serialize without inventing TLE text.
270    elements: ElementSet,
271    opsmode: OpsMode,
272    /// Pre-initialized satellite record. Boxed to keep `Satellite` small on
273    /// the stack - `ElsetRec` has ~150 fields.
274    satrec: Box<vallado::ElsetRec>,
275}
276
277impl std::fmt::Debug for Satellite {
278    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279        f.debug_struct("Satellite")
280            .field("line1", &self.line1)
281            .field("line2", &self.line2)
282            .field("elements", &self.elements)
283            .field("opsmode", &self.opsmode)
284            .finish_non_exhaustive()
285    }
286}
287
288impl Satellite {
289    /// Parse a two-line element set using the default `Improved` opsmode.
290    ///
291    /// Lines should be the standard 69-character TLE format. Leading and
292    /// trailing whitespace is trimmed before length validation. Runs the full
293    /// SGP4 initialization (`sgp4init`) once and caches the resulting state
294    /// so propagation calls are pure step kernels.
295    pub fn from_tle(line1: &str, line2: &str) -> Result<Self, Error> {
296        Self::from_tle_with_opsmode(line1, line2, OpsMode::Improved)
297    }
298
299    /// Parse a two-line element set with an explicit `OpsMode`.
300    ///
301    /// Use `OpsMode::Afspc` to reproduce results from operational AFSPC
302    /// systems or older crates that ran in AFSPC compatibility mode.
303    ///
304    /// The TLE flows through the canonical element-set IR: it is parsed by the
305    /// forgiving [`crate::astro::tle`] grammar into [`crate::astro::tle::TleElements`],
306    /// converted to an [`ElementSet`], and initialized through the single
307    /// [`Satellite::from_elements`] path - the same path OMM and any other input
308    /// format use. There is no separate TLE-direct initialization. The parse is
309    /// lenient on cosmetics (trailing content past column 69, leading-dot and
310    /// assumed-decimal field forms, an advisory checksum) but still rejects
311    /// genuinely corrupt input (non-ASCII, wrong line structure, mismatched
312    /// satellite numbers).
313    pub fn from_tle_with_opsmode(
314        line1: &str,
315        line2: &str,
316        opsmode: OpsMode,
317    ) -> Result<Self, Error> {
318        let l1 = line1.trim();
319        let l2 = line2.trim();
320
321        let parsed = tle::parse(l1, l2).map_err(|e| Error::InvalidTle(e.to_string()))?;
322        let elements = parsed
323            .elements
324            .to_element_set()
325            .map_err(map_tle_bridge_error)?;
326        let satrec = init_satrec_from_elements(&elements, opsmode)?;
327
328        Ok(Satellite {
329            line1: l1.to_string(),
330            line2: l2.to_string(),
331            elements,
332            opsmode,
333            satrec: Box::new(satrec),
334        })
335    }
336
337    /// Parse a satellite from a TLE block that may carry a leading name line.
338    ///
339    /// CelesTrak and Space-Track serve TLEs in the common "3-line" (TLE/3LE)
340    /// form: an object-name line followed by the two element lines. This accepts
341    /// that block, strips an optional leading name line, and parses the first
342    /// `1 `/`2 ` element-line pair. A plain 2-line block (no name line) also
343    /// parses. Uses the default `Improved` opsmode.
344    pub fn from_3line(block: &str) -> Result<Self, Error> {
345        Self::from_3line_with_opsmode(block, OpsMode::Improved)
346    }
347
348    /// Parse a name-line-prefixed TLE block with an explicit `OpsMode`. See
349    /// [`Satellite::from_3line`].
350    pub fn from_3line_with_opsmode(block: &str, opsmode: OpsMode) -> Result<Self, Error> {
351        let mut l1 = None;
352        let mut l2 = None;
353        for line in block.lines() {
354            let line = line.trim();
355            if l1.is_none() && line.starts_with("1 ") {
356                l1 = Some(line.to_string());
357            } else if l2.is_none() && line.starts_with("2 ") {
358                l2 = Some(line.to_string());
359            }
360        }
361        let l1 = l1.ok_or_else(|| Error::InvalidTle("no line 1 in TLE block".into()))?;
362        let l2 = l2.ok_or_else(|| Error::InvalidTle("no line 2 in TLE block".into()))?;
363        Self::from_tle_with_opsmode(&l1, &l2, opsmode)
364    }
365
366    /// Construct a `Satellite` from pre-parsed Vallado SGP4 elements using
367    /// the default `Improved` opsmode.
368    ///
369    /// Useful when TLE data has already been parsed externally (OMM, JSON
370    /// catalog, another system) and you only need the element values to flow
371    /// into SGP4 initialization. Equivalent to `from_tle` for propagation
372    /// behavior, but bypasses the TLE string parser.
373    ///
374    /// Note: a `Satellite` constructed this way has empty `line1()` and
375    /// `line2()` accessors since there is no source TLE to return.
376    pub fn from_elements(elements: &ElementSet) -> Result<Self, Error> {
377        Self::from_elements_with_opsmode(elements, OpsMode::Improved)
378    }
379
380    /// Construct a `Satellite` from pre-parsed elements with an explicit
381    /// `OpsMode`. See `from_tle_with_opsmode` for the rationale.
382    pub fn from_elements_with_opsmode(
383        elements: &ElementSet,
384        opsmode: OpsMode,
385    ) -> Result<Self, Error> {
386        let satrec = init_satrec_from_elements(elements, opsmode)?;
387        Ok(Satellite {
388            line1: String::new(),
389            line2: String::new(),
390            elements: elements.clone(),
391            opsmode,
392            satrec: Box::new(satrec),
393        })
394    }
395
396    /// Propagate to a time given as minutes since the TLE epoch.
397    ///
398    /// Calls the SGP4 step kernel directly with the supplied tsince - no JD
399    /// round-trip, no precision loss.
400    pub fn propagate(&self, t: MinutesSinceEpoch) -> Result<Prediction, Error> {
401        // Clone the satrec so propagation doesn't mutate the cached state
402        // (sgp4 writes back into the satrec - error code, atime, etc.).
403        propagate_satrec((*self.satrec).clone(), t)
404    }
405
406    /// Propagate to a Julian date, split as `(whole, fraction)`.
407    ///
408    /// Computes the tsince from the cached epoch via the same subtraction
409    /// the C++ wrapper uses:
410    ///
411    /// ```text
412    /// tsince = (jd - jdsatepoch) * 1440 + (fr - jdsatepochF) * 1440
413    /// ```
414    pub fn propagate_jd(&self, jd: JulianDate) -> Result<Prediction, Error> {
415        validate::finite(jd.0, "julian_date.whole").map_err(map_input_error)?;
416        validate::finite_in_range_exclusive_upper(jd.1, 0.0, 1.0, "julian_date.fraction")
417            .map_err(map_input_error)?;
418        let tsince =
419            (jd.0 - self.satrec.jdsatepoch) * 1440.0 + (jd.1 - self.satrec.jdsatepochF) * 1440.0;
420        validate::finite(tsince, "minutes_since_epoch").map_err(map_input_error)?;
421        self.propagate(MinutesSinceEpoch(tsince))
422    }
423
424    pub(crate) fn mean_motion_rad_per_min(&self) -> f64 {
425        self.satrec.no_kozai
426    }
427
428    pub(crate) fn eccentricity(&self) -> f64 {
429        self.satrec.ecco
430    }
431
432    /// Raw TLE line 1. Returns an empty string when this `Satellite` was
433    /// constructed via `from_elements` (no source TLE).
434    pub fn line1(&self) -> &str {
435        &self.line1
436    }
437
438    /// Raw TLE line 2. Returns an empty string when this `Satellite` was
439    /// constructed via `from_elements` (no source TLE).
440    pub fn line2(&self) -> &str {
441        &self.line2
442    }
443
444    /// Cached TLE epoch as a split Julian date `(jdsatepoch, jdsatepochF)`.
445    ///
446    /// Useful for computing time offsets between two TLEs without losing
447    /// precision through floating-point round-trips.
448    pub fn epoch_jd(&self) -> JulianDate {
449        JulianDate(self.satrec.jdsatepoch, self.satrec.jdsatepochF)
450    }
451
452    fn has_source_tle(&self) -> bool {
453        !self.line1.is_empty() && !self.line2.is_empty()
454    }
455}
456
457/// One-shot SGP4 propagation from pre-parsed elements using the default
458/// `Improved` opsmode.
459///
460/// Equivalent to `Satellite::from_elements(&e)?.propagate(t)` but without
461/// allocating a cached `Satellite`. Suitable for one-call use cases where
462/// the satellite record is not reused (e.g. NIF entry points that get
463/// elements + a single time per call).
464pub fn propagate_elements(
465    elements: &ElementSet,
466    t: MinutesSinceEpoch,
467) -> Result<Prediction, Error> {
468    propagate_elements_with_opsmode(elements, t, OpsMode::Improved)
469}
470
471/// One-shot SGP4 propagation with an explicit `OpsMode`.
472pub fn propagate_elements_with_opsmode(
473    elements: &ElementSet,
474    t: MinutesSinceEpoch,
475    opsmode: OpsMode,
476) -> Result<Prediction, Error> {
477    let satrec = init_satrec_from_elements(elements, opsmode)?;
478    propagate_satrec(satrec, t)
479}
480
481// ── Batch propagation ─────────────────────────────────────────────────
482
483/// Propagate one already-initialized satellite across every time in `times`.
484///
485/// Element `i` of the returned arc is `satellite.propagate(times[i])`, in input
486/// order. This is the single-satellite building block both batch entry points
487/// fan out over; it does not catch a per-time failure, so the first erroring
488/// time in the arc becomes the arc's `Err` (matching `Iterator::collect` into
489/// `Result`). Use the per-satellite `Result` from [`propagate_batch`] to isolate
490/// which satellite failed.
491fn propagate_arc(
492    satellite: &Satellite,
493    times: &[MinutesSinceEpoch],
494) -> Result<Vec<Prediction>, Error> {
495    times.iter().map(|&t| satellite.propagate(t)).collect()
496}
497
498/// Propagate many already-initialized satellites across a shared set of times,
499/// serially.
500///
501/// This is the most-requested throughput primitive: given `N` satellites and `M`
502/// epochs it returns one arc per satellite, each arc the `M` TEME states for that
503/// satellite. The result is indexed by satellite, so element `i` corresponds to
504/// `satellites[i]`.
505///
506/// ## Bit-identity
507///
508/// Each state is produced by calling [`Satellite::propagate`] directly, the exact
509/// single-satellite path. There is no batched algorithm, no shared mutable state,
510/// and no reordering inside a satellite's arc, so
511/// `propagate_batch(sats, times)[i]` is byte-for-byte identical to mapping
512/// `sats[i].propagate(t)` over `times` by hand. The batch is pure iteration over
513/// the proven kernel.
514///
515/// ## Per-satellite error isolation
516///
517/// One satellite that fails to propagate (an SGP4 error code, a non-finite state,
518/// an out-of-domain time) yields an `Err` in *its* slot only; every other
519/// satellite still returns its full arc. A bad satellite never aborts the batch.
520/// Within a single satellite's arc the first erroring time short-circuits that
521/// arc (the rest of that satellite's times are not propagated).
522///
523/// [`propagate_batch_parallel`] is the data-parallel variant and is proven
524/// bit-identical to this serial reference.
525///
526/// ```
527/// use sidereon_core::astro::sgp4::{propagate_batch, MinutesSinceEpoch, Satellite};
528///
529/// let l1 = "1 25544U 98067A   18184.80969102  .00001614  00000-0  31745-4 0  9993";
530/// let l2 = "2 25544  51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
531/// let sats = [Satellite::from_tle(l1, l2).unwrap()];
532/// let times = [MinutesSinceEpoch(0.0), MinutesSinceEpoch(90.0)];
533///
534/// let batch = propagate_batch(&sats, &times);
535/// let arc = batch[0].as_ref().unwrap();
536/// assert_eq!(arc.len(), times.len());
537/// ```
538pub fn propagate_batch(
539    satellites: &[Satellite],
540    times: &[MinutesSinceEpoch],
541) -> Vec<Result<Vec<Prediction>, Error>> {
542    satellites
543        .iter()
544        .map(|satellite| propagate_arc(satellite, times))
545        .collect()
546}
547
548/// Propagate many already-initialized satellites across a shared set of times,
549/// fanning the independent per-satellite arcs across a rayon thread pool.
550///
551/// Each satellite's arc is computed by the same serial [`propagate_arc`] kernel
552/// (i.e. the same [`Satellite::propagate`] calls as [`propagate_batch`]), and the
553/// indexed parallel collect preserves input order. Satellites are independent
554/// (no cross-satellite state, no reduction), so element `i` of the result is
555/// byte-for-byte identical to element `i` of [`propagate_batch`] while throughput
556/// scales with available cores. Per-satellite error isolation is identical to the
557/// serial path.
558pub fn propagate_batch_parallel(
559    satellites: &[Satellite],
560    times: &[MinutesSinceEpoch],
561) -> Vec<Result<Vec<Prediction>, Error>> {
562    use rayon::prelude::*;
563    satellites
564        .par_iter()
565        .map(|satellite| propagate_arc(satellite, times))
566        .collect()
567}
568
569impl serde::Serialize for Satellite {
570    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
571        use serde::ser::SerializeStruct;
572        let mut st = s.serialize_struct("Satellite", 2)?;
573        if self.has_source_tle() {
574            st.serialize_field("line1", &self.line1)?;
575            st.serialize_field("line2", &self.line2)?;
576        } else {
577            st.serialize_field("elements", &self.elements)?;
578            st.serialize_field("opsmode", &self.opsmode)?;
579        }
580        st.end()
581    }
582}
583
584impl<'de> serde::Deserialize<'de> for Satellite {
585    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
586        #[derive(serde::Deserialize)]
587        struct Wire {
588            line1: Option<String>,
589            line2: Option<String>,
590            elements: Option<ElementSet>,
591            opsmode: Option<OpsMode>,
592        }
593        let w = Wire::deserialize(d)?;
594        let opsmode = w.opsmode.unwrap_or_default();
595        let has_tle_line = w
596            .line1
597            .as_deref()
598            .is_some_and(|line| !line.trim().is_empty())
599            || w.line2
600                .as_deref()
601                .is_some_and(|line| !line.trim().is_empty());
602        if let Some(elements) = w.elements {
603            if has_tle_line {
604                Err(serde::de::Error::custom(
605                    "ambiguous Satellite wire format: use either TLE lines or elements",
606                ))
607            } else {
608                Satellite::from_elements_with_opsmode(&elements, opsmode)
609                    .map_err(serde::de::Error::custom)
610            }
611        } else if let (Some(line1), Some(line2)) = (w.line1, w.line2) {
612            if line1.trim().is_empty() || line2.trim().is_empty() {
613                Err(serde::de::Error::custom(
614                    "Satellite wire format requires non-empty line1/line2 or elements",
615                ))
616            } else {
617                Satellite::from_tle_with_opsmode(&line1, &line2, opsmode)
618                    .map_err(serde::de::Error::custom)
619            }
620        } else {
621            Err(serde::de::Error::custom(
622                "Satellite wire format requires non-empty line1/line2 or elements",
623            ))
624        }
625    }
626}
627
628// ── Internal helpers ─────────────────────────────────────────────────
629
630fn propagate_satrec(
631    mut satrec: vallado::ElsetRec,
632    t: MinutesSinceEpoch,
633) -> Result<Prediction, Error> {
634    validate::finite(t.0, "minutes_since_epoch").map_err(map_input_error)?;
635    if t.0.abs() > MAX_MINUTES_SINCE_EPOCH {
636        return Err(invalid_domain("minutes_since_epoch"));
637    }
638
639    let mut r = [0.0_f64; 3];
640    let mut v = [0.0_f64; 3];
641    let ok = vallado::sgp4(&mut satrec, t.0, &mut r, &mut v);
642    if !ok || satrec.error != 0 {
643        return Err(Error::Sgp4 { code: satrec.error });
644    }
645    validate_prediction(r, v)?;
646    Ok(Prediction {
647        position: r,
648        velocity: v,
649    })
650}
651
652fn validate_prediction(position: [f64; 3], velocity: [f64; 3]) -> Result<(), Error> {
653    validate::finite_vec3(position, "position_km").map_err(map_output_error)?;
654    validate::finite_vec3(velocity, "velocity_km_s").map_err(map_output_error)?;
655    Ok(())
656}
657
658/// Run `sgp4init` from a pre-parsed element set, returning the initialized
659/// satellite record. Performs the same angle/units conversion as
660/// `vallado::twoline2rv_propagate` so a `Satellite` constructed from
661/// elements is equivalent to one constructed from the matching TLE.
662fn init_satrec_from_elements(
663    elements: &ElementSet,
664    opsmode: OpsMode,
665) -> Result<vallado::ElsetRec, Error> {
666    validate_elements(elements)?;
667
668    let deg2rad = std::f64::consts::PI / 180.0;
669    let xpdotp = 1440.0 / (2.0 * std::f64::consts::PI);
670
671    let inclo = elements.inclination_deg * deg2rad;
672    let nodeo = elements.right_ascension_deg * deg2rad;
673    let argpo = elements.argument_of_perigee_deg * deg2rad;
674    let mo = elements.mean_anomaly_deg * deg2rad;
675    let no_kozai = elements.mean_motion_rev_per_day / xpdotp;
676    // ndot rev/day² → rad/min², nddot rev/day³ → rad/min³.
677    // Matches the conversion in `vallado::twoline2rv_propagate`.
678    let ndot = elements.mean_motion_dot / (xpdotp * 1440.0);
679    let nddot = elements.mean_motion_double_dot / (xpdotp * 1440.0 * 1440.0);
680
681    let JulianDate(jd, jdfrac) = elements.epoch;
682    let epoch_sgp4 = jd + jdfrac - 2433281.5;
683
684    let satnum_str = format!("{:>5}", elements.catalog_number);
685
686    let mut satrec = vallado::ElsetRec {
687        jdsatepoch: jd,
688        jdsatepochF: jdfrac,
689        ..vallado::ElsetRec::default()
690    };
691
692    vallado::sgp4init(
693        vallado::GravConstType::Wgs72,
694        opsmode.as_char(),
695        &satnum_str,
696        epoch_sgp4,
697        elements.bstar,
698        ndot,
699        nddot,
700        elements.eccentricity,
701        argpo,
702        inclo,
703        mo,
704        no_kozai,
705        nodeo,
706        &mut satrec,
707    );
708
709    // sgp4init may have rewritten jdsatepoch via initl - restore the split.
710    satrec.jdsatepoch = jd;
711    satrec.jdsatepochF = jdfrac;
712
713    Ok(satrec)
714}
715
716fn validate_elements(elements: &ElementSet) -> Result<(), Error> {
717    if elements.catalog_number > MAX_VALLADO_SATNUM {
718        return Err(invalid_domain("element.catalog_number"));
719    }
720    validate_epoch(elements.epoch)?;
721    validate::finite(elements.bstar, "element.bstar").map_err(map_input_error)?;
722    validate::finite(elements.mean_motion_dot, "element.mean_motion_dot")
723        .map_err(map_input_error)?;
724    validate::finite(
725        elements.mean_motion_double_dot,
726        "element.mean_motion_double_dot",
727    )
728    .map_err(map_input_error)?;
729    validate::finite_in_range_exclusive_upper(
730        elements.eccentricity,
731        0.0,
732        1.0,
733        "element.eccentricity",
734    )
735    .map_err(map_input_error)?;
736    validate::finite(
737        elements.argument_of_perigee_deg,
738        "element.argument_of_perigee_deg",
739    )
740    .map_err(map_input_error)?;
741    validate::finite(elements.inclination_deg, "element.inclination_deg")
742        .map_err(map_input_error)?;
743    validate::finite(elements.mean_anomaly_deg, "element.mean_anomaly_deg")
744        .map_err(map_input_error)?;
745    validate::finite_positive(
746        elements.mean_motion_rev_per_day,
747        "element.mean_motion_rev_per_day",
748    )
749    .map_err(map_input_error)?;
750    validate::finite(elements.right_ascension_deg, "element.right_ascension_deg")
751        .map_err(map_input_error)?;
752
753    Ok(())
754}
755
756fn validate_epoch(epoch: JulianDate) -> Result<(), Error> {
757    validate::finite(epoch.0, "element.epoch.whole").map_err(map_input_error)?;
758    validate::finite(epoch.1, "element.epoch.fraction").map_err(map_input_error)?;
759
760    let total = epoch.0 + epoch.1;
761    validate::finite(total, "element.epoch").map_err(map_input_error)?;
762    if !(0.0..=5_000_000.0).contains(&total) {
763        return Err(invalid_domain("element.epoch"));
764    }
765    Ok(())
766}
767
768fn map_input_error(error: FieldError) -> Error {
769    Error::InvalidInput {
770        field: error.field(),
771        kind: Sgp4InputErrorKind::from(&error),
772    }
773}
774
775fn invalid_domain(field: &'static str) -> Error {
776    Error::InvalidInput {
777        field,
778        kind: Sgp4InputErrorKind::OutOfRange,
779    }
780}
781
782fn map_tle_bridge_error(error: tle::TleError) -> Error {
783    match error {
784        tle::TleError::InvalidField { field, reason } => Error::InvalidInput {
785            field,
786            kind: match reason {
787                "not finite" => Sgp4InputErrorKind::NonFinite,
788                "not positive" => Sgp4InputErrorKind::NotPositive,
789                "negative" => Sgp4InputErrorKind::Negative,
790                "out of range" => Sgp4InputErrorKind::OutOfRange,
791                _ => Sgp4InputErrorKind::OutOfRange,
792            },
793        },
794        other => Error::InvalidTle(other.to_string()),
795    }
796}
797
798fn map_output_error(error: FieldError) -> Error {
799    Error::NonFiniteOutput {
800        field: error.field(),
801    }
802}
803
804/// A satellite parsed from a TLE file, paired with its name line (if any).
805#[derive(Debug)]
806pub struct NamedSatellite {
807    /// The name line preceding the element set in a 3-line set, with a leading
808    /// CelesTrak `0 ` marker stripped. Empty when the record is a bare 2-line
809    /// set with no name.
810    pub name: String,
811    /// The initialized satellite.
812    pub satellite: Satellite,
813}
814
815/// The result of parsing a multi-record TLE file: the satellites that parsed,
816/// plus a count of records that were skipped.
817#[derive(Debug)]
818pub struct TleFile {
819    /// The successfully parsed satellites, in file order.
820    pub satellites: Vec<NamedSatellite>,
821    /// How many complete `(line 1, line 2)` records were found but skipped
822    /// because their element set failed SGP4 initialization. Lets callers tell
823    /// an empty file (`satellites` empty, `skipped == 0`) apart from a fully
824    /// corrupt one (`skipped > 0`), without aborting the whole parse on one bad
825    /// entry. Use [`Satellite::from_tle`] per record when you need the error.
826    pub skipped: usize,
827}
828
829/// Parse a multi-record TLE file (CelesTrak / Space-Track style) into satellites
830/// with their names. Uses the default `Improved` opsmode.
831///
832/// Handles, in a single pass, the common variants: bare 2-line element sets,
833/// 3-line sets (a name line followed by lines 1 and 2), and CelesTrak `0 NAME`
834/// name lines. Blank lines, CRLF endings, and surrounding whitespace are
835/// tolerated. A record whose element set fails SGP4 initialization is skipped
836/// and counted in [`TleFile::skipped`] rather than aborting the whole file.
837pub fn parse_tle_file(text: &str) -> TleFile {
838    parse_tle_file_with_opsmode(text, OpsMode::Improved)
839}
840
841/// [`parse_tle_file`] with an explicit [`OpsMode`].
842pub fn parse_tle_file_with_opsmode(text: &str, opsmode: OpsMode) -> TleFile {
843    let lines: Vec<&str> = text.lines().map(str::trim).collect();
844    let mut satellites = Vec::new();
845    let mut skipped = 0usize;
846    let mut pending_name = String::new();
847    let mut i = 0;
848    while i < lines.len() {
849        let line = lines[i];
850        if line.is_empty() {
851            i += 1;
852            continue;
853        }
854        if line.starts_with("1 ") {
855            // A line 1: the next non-empty line must be its line 2.
856            let mut j = i + 1;
857            while j < lines.len() && lines[j].is_empty() {
858                j += 1;
859            }
860            if j < lines.len() && lines[j].starts_with("2 ") {
861                if let Ok(satellite) = Satellite::from_tle_with_opsmode(line, lines[j], opsmode) {
862                    satellites.push(NamedSatellite {
863                        name: std::mem::take(&mut pending_name),
864                        satellite,
865                    });
866                } else {
867                    skipped += 1;
868                    pending_name.clear();
869                }
870                i = j + 1;
871                continue;
872            }
873            // Stray line 1 with no following line 2: drop the pending name, resync.
874            pending_name.clear();
875            i += 1;
876            continue;
877        }
878        if line.starts_with("2 ") {
879            // Stray line 2 with no preceding line 1: skip it and drop any pending
880            // name so it can't attach to a later record.
881            pending_name.clear();
882            i += 1;
883            continue;
884        }
885        // Any other non-empty line is a name line (3LE name or CelesTrak "0 NAME").
886        pending_name = line.strip_prefix("0 ").unwrap_or(line).trim().to_string();
887        i += 1;
888    }
889    TleFile {
890        satellites,
891        skipped,
892    }
893}
894
895#[cfg(test)]
896mod tests {
897    use super::{
898        parse_tle_file, propagate_batch, propagate_batch_parallel, propagate_elements, ElementSet,
899        Error, JulianDate, MinutesSinceEpoch, Satellite, Sgp4InputErrorKind,
900        MAX_MINUTES_SINCE_EPOCH,
901    };
902
903    /// A TLE carrying a multibyte character inside a fixed-width column must
904    /// return a typed [`Error::InvalidTle`] rather than panicking. The field
905    /// extractor slices by byte column (`l1[18..20]`, ...); a non-ASCII byte
906    /// inside such a window used to panic on a non-char-boundary slice. The
907    /// ASCII guard now rejects the line with a typed error; valid input parses.
908    #[test]
909    fn non_ascii_tle_returns_invalid_tle_not_panic() {
910        let line1 = "1 25544U 98067A   18184.80969102  .00001614  00000-0  31745-4 0  9993";
911        let line2 = "2 25544  51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
912        assert!(
913            Satellite::from_tle(line1, line2).is_ok(),
914            "clean ASCII TLE must still parse"
915        );
916
917        // Drop a 3-byte character into the epoch-year column (bytes 18..20),
918        // straddling byte 20 so the pre-guard `l1[18..20]` slice would panic.
919        let mut bad1 = String::from(&line1[..18]);
920        bad1.push('\u{20ac}');
921        bad1.push_str(&line1[19..]);
922        assert!(
923            !bad1.is_char_boundary(20),
924            "corruption must straddle byte 20"
925        );
926
927        let err = Satellite::from_tle(&bad1, line2).expect_err("non-ASCII TLE must not parse");
928        assert!(
929            matches!(err, Error::InvalidTle(_)),
930            "expected a typed InvalidTle error, got: {err:?}"
931        );
932    }
933
934    // ── Forgiving-inbound leniency (now that `from_tle` flows through the
935    // canonical IR via the lenient `tle` parser) ───────────────────────────
936
937    const ISS_L1: &str = "1 25544U 98067A   18184.80969102  .00001614  00000-0  31745-4 0  9993";
938    const ISS_L2: &str = "2 25544  51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
939
940    #[test]
941    fn parse_tle_file_three_line_captures_names() {
942        let text = format!("ISS (ZARYA)\n{ISS_L1}\n{ISS_L2}\nSECOND SAT\n{ISS_L1}\n{ISS_L2}\n");
943        let f = parse_tle_file(&text);
944        assert_eq!(f.satellites.len(), 2);
945        assert_eq!(f.skipped, 0);
946        assert_eq!(f.satellites[0].name, "ISS (ZARYA)");
947        assert_eq!(f.satellites[1].name, "SECOND SAT");
948        assert_eq!(f.satellites[0].satellite.line1(), ISS_L1);
949        assert_eq!(f.satellites[0].satellite.line2(), ISS_L2);
950    }
951
952    #[test]
953    fn parse_tle_file_bare_two_line_has_empty_name() {
954        let f = parse_tle_file(&format!("{ISS_L1}\n{ISS_L2}"));
955        assert_eq!(f.satellites.len(), 1);
956        assert_eq!(f.satellites[0].name, "");
957        assert_eq!(f.satellites[0].satellite.line1(), ISS_L1);
958    }
959
960    #[test]
961    fn parse_tle_file_strips_celestrak_zero_name_marker() {
962        let f = parse_tle_file(&format!("0 ISS (ZARYA)\n{ISS_L1}\n{ISS_L2}"));
963        assert_eq!(f.satellites.len(), 1);
964        assert_eq!(f.satellites[0].name, "ISS (ZARYA)");
965    }
966
967    #[test]
968    fn parse_tle_file_tolerates_crlf_blanks_and_whitespace() {
969        let text = format!("\r\n  ISS (ZARYA)  \r\n{ISS_L1}\r\n\r\n{ISS_L2}\r\n\r\n");
970        let f = parse_tle_file(&text);
971        assert_eq!(f.satellites.len(), 1);
972        assert_eq!(f.satellites[0].name, "ISS (ZARYA)");
973        assert_eq!(f.satellites[0].satellite.line1(), ISS_L1);
974    }
975
976    #[test]
977    fn parse_tle_file_skips_malformed_record_and_counts_it() {
978        // A bad record (line1/line2 that fail SGP4 init) sits between two good
979        // ones; it is skipped, counted, and its name does not attach to the next.
980        let text = format!(
981            "GOOD ONE\n{ISS_L1}\n{ISS_L2}\nBAD ONE\n1 not a real line\n2 not a real line\nGOOD TWO\n{ISS_L1}\n{ISS_L2}\n"
982        );
983        let f = parse_tle_file(&text);
984        assert_eq!(
985            f.satellites.len(),
986            2,
987            "the malformed record must be skipped"
988        );
989        assert_eq!(f.skipped, 1, "the skipped record must be counted");
990        assert_eq!(f.satellites[0].name, "GOOD ONE");
991        assert_eq!(f.satellites[1].name, "GOOD TWO");
992    }
993
994    #[test]
995    fn parse_tle_file_stray_line2_does_not_leak_name() {
996        // A name line followed by a stray line 2 (no line 1), then a bare valid
997        // 2-line set: the stray name must NOT attach to the later record.
998        let text = format!("ORPHAN NAME\n2 stray line two\n{ISS_L1}\n{ISS_L2}\n");
999        let f = parse_tle_file(&text);
1000        assert_eq!(f.satellites.len(), 1);
1001        assert_eq!(f.satellites[0].name, "", "stray name must not leak forward");
1002    }
1003
1004    fn iss_elements() -> ElementSet {
1005        crate::astro::tle::parse(ISS_L1, ISS_L2)
1006            .unwrap()
1007            .elements
1008            .to_element_set()
1009            .expect("valid TLE bridge")
1010    }
1011
1012    fn assert_invalid_input<T>(
1013        result: Result<T, Error>,
1014        field: &'static str,
1015        kind: Sgp4InputErrorKind,
1016    ) {
1017        match result {
1018            Err(Error::InvalidInput {
1019                field: actual_field,
1020                kind: actual_kind,
1021            }) => {
1022                assert_eq!(actual_field, field);
1023                assert_eq!(actual_kind, kind);
1024            }
1025            Err(err) => panic!("expected InvalidInput({field}, {kind}), got {err:?}"),
1026            Ok(_) => panic!("expected InvalidInput({field}, {kind}), got Ok"),
1027        }
1028    }
1029
1030    /// Assert two satellites are bit-identical (same cached epoch and same
1031    /// propagated state at several offsets).
1032    fn assert_same(a: &Satellite, b: &Satellite) {
1033        let (ea, eb) = (a.epoch_jd(), b.epoch_jd());
1034        assert_eq!(
1035            (ea.0.to_bits(), ea.1.to_bits()),
1036            (eb.0.to_bits(), eb.1.to_bits()),
1037            "epoch JD differs"
1038        );
1039        for &t in &[0.0, 100.0, 1440.0] {
1040            let pa = a.propagate(MinutesSinceEpoch(t)).unwrap();
1041            let pb = b.propagate(MinutesSinceEpoch(t)).unwrap();
1042            for axis in 0..3 {
1043                assert_eq!(
1044                    pa.position[axis].to_bits(),
1045                    pb.position[axis].to_bits(),
1046                    "position[{axis}] differs at t={t}"
1047                );
1048                assert_eq!(
1049                    pa.velocity[axis].to_bits(),
1050                    pb.velocity[axis].to_bits(),
1051                    "velocity[{axis}] differs at t={t}"
1052                );
1053            }
1054        }
1055    }
1056
1057    #[test]
1058    fn serde_round_trips_tle_satellites() {
1059        let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1060        let encoded = serde_json::to_string(&sat).unwrap();
1061        assert!(encoded.contains("\"line1\""));
1062        assert!(encoded.contains("\"line2\""));
1063        assert!(!encoded.contains("\"elements\""));
1064
1065        let decoded: Satellite = serde_json::from_str(&encoded).unwrap();
1066        assert_eq!(decoded.line1(), ISS_L1);
1067        assert_eq!(decoded.line2(), ISS_L2);
1068        assert_same(&sat, &decoded);
1069    }
1070
1071    #[test]
1072    fn serde_round_trips_element_built_satellites() {
1073        let elements = iss_elements();
1074        let sat = Satellite::from_elements(&elements).unwrap();
1075        let encoded = serde_json::to_string(&sat).unwrap();
1076        assert!(encoded.contains("\"elements\""));
1077        assert!(encoded.contains("\"opsmode\""));
1078        assert!(!encoded.contains("\"line1\""));
1079        assert!(!encoded.contains("\"line2\""));
1080
1081        let decoded: Satellite = serde_json::from_str(&encoded).unwrap();
1082        assert!(decoded.line1().is_empty());
1083        assert!(decoded.line2().is_empty());
1084        assert_same(&sat, &decoded);
1085    }
1086
1087    #[test]
1088    fn from_elements_rejects_non_finite_fields_before_sgp4init() {
1089        let mut elements = iss_elements();
1090        elements.bstar = f64::NAN;
1091
1092        assert_invalid_input(
1093            Satellite::from_elements(&elements),
1094            "element.bstar",
1095            Sgp4InputErrorKind::NonFinite,
1096        );
1097    }
1098
1099    #[test]
1100    fn from_elements_rejects_sgp4_domain_before_sgp4init() {
1101        let mut elements = iss_elements();
1102        elements.mean_motion_rev_per_day = 0.0;
1103        assert_invalid_input(
1104            Satellite::from_elements(&elements),
1105            "element.mean_motion_rev_per_day",
1106            Sgp4InputErrorKind::NotPositive,
1107        );
1108
1109        let mut elements = iss_elements();
1110        elements.eccentricity = -0.1;
1111        assert_invalid_input(
1112            Satellite::from_elements(&elements),
1113            "element.eccentricity",
1114            Sgp4InputErrorKind::OutOfRange,
1115        );
1116
1117        let mut elements = iss_elements();
1118        elements.eccentricity = 1.0;
1119        assert_invalid_input(
1120            Satellite::from_elements(&elements),
1121            "element.eccentricity",
1122            Sgp4InputErrorKind::OutOfRange,
1123        );
1124
1125        let mut elements = iss_elements();
1126        elements.catalog_number = 100_000;
1127        assert_invalid_input(
1128            Satellite::from_elements(&elements),
1129            "element.catalog_number",
1130            Sgp4InputErrorKind::OutOfRange,
1131        );
1132    }
1133
1134    #[test]
1135    fn from_elements_rejects_invalid_epoch() {
1136        let mut elements = iss_elements();
1137        elements.epoch = JulianDate(f64::NAN, 0.0);
1138        assert_invalid_input(
1139            Satellite::from_elements(&elements),
1140            "element.epoch.whole",
1141            Sgp4InputErrorKind::NonFinite,
1142        );
1143
1144        let mut elements = iss_elements();
1145        elements.epoch = JulianDate(9_000_000.0, 0.0);
1146        assert_invalid_input(
1147            Satellite::from_elements(&elements),
1148            "element.epoch",
1149            Sgp4InputErrorKind::OutOfRange,
1150        );
1151    }
1152
1153    #[test]
1154    fn from_elements_accepts_full_julian_epoch() {
1155        let mut elements = iss_elements();
1156        elements.epoch = super::sgp4_julian_date_from_calendar(2057, 1, 1, 0, 0, 0.0);
1157        Satellite::from_elements(&elements).expect("full 2057 epoch is valid");
1158    }
1159
1160    #[test]
1161    fn from_tle_accepts_epoch_after_parser_conversion_to_full_jd() {
1162        let mut line1 = ISS_L1.to_string();
1163        line1.replace_range(18..32, "19366.00000000");
1164
1165        Satellite::from_tle(&line1, ISS_L2).expect("TLE epoch is converted to full JD");
1166    }
1167
1168    #[test]
1169    fn propagation_rejects_non_finite_time_inputs() {
1170        let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1171        assert_invalid_input(
1172            sat.propagate(MinutesSinceEpoch(f64::NAN)),
1173            "minutes_since_epoch",
1174            Sgp4InputErrorKind::NonFinite,
1175        );
1176        assert_invalid_input(
1177            sat.propagate_jd(JulianDate(f64::INFINITY, 0.0)),
1178            "julian_date.whole",
1179            Sgp4InputErrorKind::NonFinite,
1180        );
1181
1182        let elements = iss_elements();
1183        assert_invalid_input(
1184            propagate_elements(&elements, MinutesSinceEpoch(f64::INFINITY)),
1185            "minutes_since_epoch",
1186            Sgp4InputErrorKind::NonFinite,
1187        );
1188    }
1189
1190    #[test]
1191    fn propagation_rejects_out_of_domain_time_inputs() {
1192        let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1193        assert_invalid_input(
1194            sat.propagate(MinutesSinceEpoch(MAX_MINUTES_SINCE_EPOCH.next_up())),
1195            "minutes_since_epoch",
1196            Sgp4InputErrorKind::OutOfRange,
1197        );
1198        assert_invalid_input(
1199            sat.propagate_jd(JulianDate(2_458_304.0, 1.0)),
1200            "julian_date.fraction",
1201            Sgp4InputErrorKind::OutOfRange,
1202        );
1203    }
1204
1205    #[test]
1206    fn lenient_trailing_whitespace_and_content_past_col_69() {
1207        let clean = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1208
1209        // Trailing whitespace on both lines.
1210        let pad = Satellite::from_tle(&format!("{ISS_L1}   "), &format!("{ISS_L2}\t ")).unwrap();
1211        assert_same(&clean, &pad);
1212
1213        // Extra content past the 69-column record (CelesTrak/Space-Track blobs
1214        // sometimes carry it); it is trimmed before parsing.
1215        let extra =
1216            Satellite::from_tle(&format!("{ISS_L1} EXTRA-JUNK"), &format!("{ISS_L2} 999999"))
1217                .unwrap();
1218        assert_same(&clean, &extra);
1219    }
1220
1221    #[test]
1222    fn lenient_leading_dot_and_assumed_decimal_fields() {
1223        // The ISS TLE already carries a leading-dot first derivative
1224        // (` .00001614`), an assumed-decimal B\* (`31745-4`), and the implicit
1225        // `0.` eccentricity (`0003435`). A successful parse + finite LEO state
1226        // proves those normalizations run through the public entry point.
1227        let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1228        let p = sat.propagate(MinutesSinceEpoch(0.0)).unwrap();
1229        let r = (p.position[0].powi(2) + p.position[1].powi(2) + p.position[2].powi(2)).sqrt();
1230        assert!(
1231            (6500.0..=7200.0).contains(&r),
1232            "ISS radius {r} km outside LEO"
1233        );
1234    }
1235
1236    #[test]
1237    fn lenient_missing_optional_bookkeeping_fields() {
1238        // Blank element-set number, ephemeris type, and revolution number are
1239        // cosmetic for propagation and must default rather than reject. Build
1240        // such a TLE by blanking those columns (cols 63, 65-68 on line 1 and
1241        // 64-68 on line 2) while leaving the orbital fields intact.
1242        let l1: String = ISS_L1
1243            .char_indices()
1244            .map(|(i, c)| {
1245                if i == 62 || (64..=67).contains(&i) {
1246                    ' '
1247                } else {
1248                    c
1249                }
1250            })
1251            .collect();
1252        let l2: String = ISS_L2
1253            .char_indices()
1254            .map(|(i, c)| if (63..=67).contains(&i) { ' ' } else { c })
1255            .collect();
1256        // Same orbital elements as the clean TLE → bit-identical propagation
1257        // (the blanked fields do not feed SGP4).
1258        let clean = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1259        let blanked = Satellite::from_tle(&l1, &l2).unwrap();
1260        assert_same(&clean, &blanked);
1261    }
1262
1263    #[test]
1264    fn three_line_form_strips_name_line() {
1265        let clean = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1266
1267        let block = format!("ISS (ZARYA)\n{ISS_L1}\n{ISS_L2}\n");
1268        let three = Satellite::from_3line(&block).unwrap();
1269        assert_same(&clean, &three);
1270
1271        // A plain two-line block (no name line) also parses.
1272        let two = Satellite::from_3line(&format!("{ISS_L1}\n{ISS_L2}")).unwrap();
1273        assert_same(&clean, &two);
1274    }
1275
1276    #[test]
1277    fn three_line_form_rejects_block_without_element_lines() {
1278        assert!(Satellite::from_3line("just a name\nand some text").is_err());
1279        assert!(Satellite::from_3line("").is_err());
1280    }
1281
1282    // ── Batch propagation ───────────────────────────────────────────────
1283
1284    // A second, distinct clean LEO TLE (Tiangong / CSS) so the batch carries
1285    // more than one well-behaved satellite.
1286    const CSS_L1: &str = "1 48274U 21035A   24001.50000000  .00015000  00000-0  18000-3 0  9990";
1287    const CSS_L2: &str = "2 48274  41.4700 100.0000 0006000  90.0000 270.0000 15.61000000 10000";
1288
1289    // NORAD 28872 from the Vallado verification set: a fast-decaying object that
1290    // propagates cleanly at small tsince but returns SGP4 error code 6 (decayed)
1291    // by t = 1440 min. Used to prove per-satellite error isolation. Trailing
1292    // content past column 69 is tolerated by the lenient parser.
1293    const DECAY_L1: &str = "1 28872U 05037B   05333.02012661  .25992681  00000-0  24476-3 0  1534";
1294    const DECAY_L2: &str = "2 28872  96.4736 157.9986 0303955 244.0492 110.6523 16.46015938 10708";
1295
1296    fn batch_times() -> Vec<MinutesSinceEpoch> {
1297        // 33 epochs spaced 45 min over a day: enough work for the parallel pool
1298        // to interleave many independent arcs.
1299        (0..33)
1300            .map(|i| MinutesSinceEpoch(i as f64 * 45.0))
1301            .collect()
1302    }
1303
1304    /// The batch result must equal a hand-rolled loop of the single-satellite
1305    /// `Satellite::propagate` path, bit-for-bit, for every satellite, epoch, and
1306    /// axis. This anchors the batch to the proven 0-ULP single-shot kernel.
1307    #[test]
1308    fn batch_is_bit_identical_to_per_satellite_propagate() {
1309        let satellites = [
1310            Satellite::from_tle(ISS_L1, ISS_L2).unwrap(),
1311            Satellite::from_tle(CSS_L1, CSS_L2).unwrap(),
1312        ];
1313        let times = batch_times();
1314
1315        let batch = propagate_batch(&satellites, &times);
1316        assert_eq!(batch.len(), satellites.len());
1317
1318        for (sat_idx, satellite) in satellites.iter().enumerate() {
1319            let arc = batch[sat_idx]
1320                .as_ref()
1321                .expect("clean satellite arc must be Ok");
1322            assert_eq!(arc.len(), times.len());
1323            for (epoch_idx, &t) in times.iter().enumerate() {
1324                let reference = satellite.propagate(t).expect("per-sat propagate ok");
1325                for axis in 0..3 {
1326                    assert_eq!(
1327                        arc[epoch_idx].position[axis].to_bits(),
1328                        reference.position[axis].to_bits(),
1329                        "position bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
1330                    );
1331                    assert_eq!(
1332                        arc[epoch_idx].velocity[axis].to_bits(),
1333                        reference.velocity[axis].to_bits(),
1334                        "velocity bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
1335                    );
1336                }
1337            }
1338        }
1339    }
1340
1341    /// The rayon-parallel batch must equal the serial batch to_bits, element by
1342    /// element. Independent arcs, indexed collect, no reordering inside an arc.
1343    #[test]
1344    fn parallel_batch_is_bit_identical_to_serial() {
1345        let satellites = [
1346            Satellite::from_tle(ISS_L1, ISS_L2).unwrap(),
1347            Satellite::from_tle(CSS_L1, CSS_L2).unwrap(),
1348            Satellite::from_tle(ISS_L1, ISS_L2).unwrap(),
1349        ];
1350        let times = batch_times();
1351
1352        let serial = propagate_batch(&satellites, &times);
1353        let parallel = propagate_batch_parallel(&satellites, &times);
1354        assert_eq!(serial.len(), parallel.len());
1355
1356        for sat_idx in 0..satellites.len() {
1357            let s = serial[sat_idx].as_ref().expect("serial arc ok");
1358            let p = parallel[sat_idx].as_ref().expect("parallel arc ok");
1359            assert_eq!(s.len(), p.len());
1360            for epoch_idx in 0..times.len() {
1361                for axis in 0..3 {
1362                    assert_eq!(
1363                        s[epoch_idx].position[axis].to_bits(),
1364                        p[epoch_idx].position[axis].to_bits(),
1365                        "position bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
1366                    );
1367                    assert_eq!(
1368                        s[epoch_idx].velocity[axis].to_bits(),
1369                        p[epoch_idx].velocity[axis].to_bits(),
1370                        "velocity bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
1371                    );
1372                }
1373            }
1374        }
1375    }
1376
1377    /// One failing satellite must not poison the rest of the batch: it yields an
1378    /// `Err` in its own slot while the clean satellites return full arcs. The
1379    /// decaying object errors at t = 1440 min but is fine earlier; a clean
1380    /// satellite spans the same grid without error.
1381    #[test]
1382    fn failing_satellite_yields_per_item_error_without_poisoning_batch() {
1383        let clean_a = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1384        let decay = Satellite::from_tle(DECAY_L1, DECAY_L2).unwrap();
1385        let clean_b = Satellite::from_tle(CSS_L1, CSS_L2).unwrap();
1386
1387        // Pre-flight: the decaying object really does error somewhere on the grid
1388        // while a clean satellite does not. Guards the fixture against drift.
1389        let times: Vec<MinutesSinceEpoch> = (0..=24)
1390            .map(|i| MinutesSinceEpoch(i as f64 * 120.0))
1391            .collect();
1392        assert!(
1393            times.iter().any(|&t| decay.propagate(t).is_err()),
1394            "decaying fixture must error on the grid"
1395        );
1396        assert!(
1397            times.iter().all(|&t| clean_a.propagate(t).is_ok()),
1398            "clean fixture must span the grid"
1399        );
1400
1401        let satellites = [clean_a, decay, clean_b];
1402        for batch in [
1403            propagate_batch(&satellites, &times),
1404            propagate_batch_parallel(&satellites, &times),
1405        ] {
1406            assert_eq!(batch.len(), 3);
1407            // The two clean satellites are unaffected by the bad one.
1408            assert!(batch[0].is_ok(), "clean satellite 0 must survive");
1409            assert_eq!(batch[0].as_ref().unwrap().len(), times.len());
1410            assert!(batch[2].is_ok(), "clean satellite 2 must survive");
1411            assert_eq!(batch[2].as_ref().unwrap().len(), times.len());
1412            // The decaying satellite surfaces a typed SGP4 error in its own slot.
1413            assert!(
1414                matches!(batch[1], Err(Error::Sgp4 { .. })),
1415                "decaying satellite must yield an SGP4 error, got {:?}",
1416                batch[1]
1417            );
1418        }
1419    }
1420
1421    #[test]
1422    fn batch_handles_empty_inputs() {
1423        let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1424        let times = batch_times();
1425
1426        // No satellites: empty result.
1427        assert!(propagate_batch(&[], &times).is_empty());
1428        assert!(propagate_batch_parallel(&[], &times).is_empty());
1429
1430        // No times: one empty arc per satellite (still Ok).
1431        let no_times = propagate_batch(std::slice::from_ref(&sat), &[]);
1432        assert_eq!(no_times.len(), 1);
1433        assert!(no_times[0].as_ref().unwrap().is_empty());
1434    }
1435
1436    #[test]
1437    fn rejects_genuine_corruption() {
1438        // Empty.
1439        assert!(Satellite::from_tle("", "").is_err());
1440        // Non-TLE text.
1441        assert!(Satellite::from_tle("hello world", "goodbye world").is_err());
1442        // Swapped lines (line 2 first).
1443        assert!(Satellite::from_tle(ISS_L2, ISS_L1).is_err());
1444        // Mismatched satellite numbers between line 1 and line 2.
1445        let l2_wrong = "2 25545  51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
1446        assert!(matches!(
1447            Satellite::from_tle(ISS_L1, l2_wrong),
1448            Err(Error::InvalidTle(_))
1449        ));
1450    }
1451}