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