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