Skip to main content

sidereon_core/astro/sgp4/
fit.rs

1//! Inverse SGP4 fitting from a TEME state arc.
2//!
3//! The fitter turns target TEME samples into SGP4 mean elements by minimizing
4//! position and optional velocity residuals with the workspace trust-region
5//! least-squares solver. The optimization variables are mean equinoctial
6//! coordinates: mean motion, the eccentricity vector projected through longitude
7//! of perigee, the inclination vector projected through RAAN, mean longitude,
8//! and optionally scaled B*. This avoids the circular-orbit and equatorial-orbit
9//! singularities of direct TLE angles while still mapping algebraically back to
10//! the SGP4 `ElementSet`.
11//!
12//! The initial point comes from an osculating `rv2coe` state at the selected fit
13//! epoch, followed by two guarded fixed-point passes through SGP4 at zero
14//! elapsed time. Infeasible trial points do not throw from the residual callback:
15//! they fill the fixed residual length with a large finite penalty so the trust
16//! region can reject the step and continue. B* is scaled by a typical LEO
17//! magnitude before optimization so finite differences can see the drag column.
18//!
19//! The returned elements are full precision. The TLE lines are fixed-width and
20//! therefore quantized; `tle_rms_position_km` reports the downstream effect of
21//! re-parsing those lines. The OMM carries the same full-precision fields as
22//! plain data. Epochs are treated as UTC split Julian dates and propagated with
23//! the same split-JD subtraction used by operational SGP4 consumers.
24
25use std::cell::Cell;
26
27use thiserror::Error;
28pub use trust_region_least_squares::loss::Loss;
29use trust_region_least_squares::model::{solve_model, ResidualModel};
30pub use trust_region_least_squares::trf::XScale;
31use trust_region_least_squares::trf::{TrfError, TrfOptions, TrfResult};
32
33use crate::astro::anomaly::true_to_mean;
34use crate::astro::elements::{rv2coe, ClassicalElements, OrbitType};
35use crate::astro::math::vec3;
36use crate::astro::omm::Omm;
37use crate::astro::sgp4::{ElementSet, Error as Sgp4Error, JulianDate, OpsMode, Satellite};
38use crate::astro::time::civil::{civil_from_split_julian_date, day_of_year};
39use crate::astro::tle::{self, TleElements};
40
41use super::MAX_MINUTES_SINCE_EPOCH;
42
43const BSTAR_SCALE: f64 = 1.0e-4;
44const ECC_MAX: f64 = 0.999;
45const PENALTY_KM: f64 = 1.0e6;
46const SEED_REFINE_PASSES: usize = 2;
47/// Observability floor for the fitted B\* Jacobian column, as a fraction of
48/// the largest column norm. This is a diagnostic threshold feeding
49/// [`FitStatistics::bstar_observable`], not a solver parameter: it never
50/// changes the fit, only whether the recovered B\* is reported as trustworthy.
51/// 1.0e-5 is calibrated on the regression arcs: at that column-norm ratio and
52/// above, B\* recovery is demonstrably reliable (the high-drag decay arc
53/// recovers B\* to within 1e-6 of truth), while arcs whose ratio falls below
54/// it (the short GEO arc) carry no usable drag signal.
55const BSTAR_OBSERVABLE_REL: f64 = 1.0e-5;
56const MU_WGS72_KM3_S2: f64 = 398_600.8;
57const SECONDS_PER_DAY: f64 = 86_400.0;
58const TAU: f64 = std::f64::consts::TAU;
59
60/// One target state on the arc: UTC epoch, TEME position, and optional TEME
61/// velocity.
62#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
63pub struct FitSample {
64    pub epoch: JulianDate,
65    pub position_teme_km: [f64; 3],
66    pub velocity_teme_km_s: Option<[f64; 3]>,
67}
68
69/// Which sample epoch becomes the TLE epoch.
70#[derive(Debug, Clone, Copy, PartialEq, Default, serde::Serialize, serde::Deserialize)]
71pub enum FitEpoch {
72    /// The sample nearest the arc midpoint.
73    #[default]
74    Midpoint,
75    First,
76    Last,
77    /// A specific sample index.
78    Sample(usize),
79    /// An explicit epoch inside the sample arc.
80    Jd(JulianDate),
81}
82
83/// Pass-through TLE/OMM bookkeeping.
84#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
85pub struct TleMetadata {
86    pub catalog_number: u32,
87    pub classification: String,
88    pub international_designator: String,
89    pub element_set_number: i32,
90    pub rev_at_epoch: i64,
91    pub object_name: String,
92}
93
94impl Default for TleMetadata {
95    fn default() -> Self {
96        Self {
97            catalog_number: 0,
98            classification: "U".to_string(),
99            international_designator: String::new(),
100            element_set_number: 999,
101            rev_at_epoch: 0,
102            object_name: String::new(),
103        }
104    }
105}
106
107/// Configuration for [`fit_tle`].
108#[derive(Debug, Clone, PartialEq)]
109pub struct FitConfig {
110    pub epoch: FitEpoch,
111    pub fit_bstar: bool,
112    pub bstar_seed: f64,
113    pub use_velocity: bool,
114    pub velocity_weight_s: Option<f64>,
115    pub weights: Option<Vec<f64>>,
116    pub opsmode: OpsMode,
117    pub ftol: Option<f64>,
118    pub xtol: Option<f64>,
119    pub gtol: Option<f64>,
120    pub max_nfev: Option<usize>,
121    pub x_scale: Option<XScale>,
122    pub loss: Loss,
123    pub f_scale: f64,
124    pub metadata: TleMetadata,
125}
126
127impl Default for FitConfig {
128    fn default() -> Self {
129        Self {
130            epoch: FitEpoch::default(),
131            fit_bstar: true,
132            bstar_seed: 0.0,
133            use_velocity: true,
134            velocity_weight_s: None,
135            weights: None,
136            opsmode: OpsMode::Improved,
137            ftol: None,
138            xtol: None,
139            gtol: None,
140            max_nfev: None,
141            x_scale: None,
142            loss: Loss::Linear,
143            f_scale: 1.0,
144            metadata: TleMetadata::default(),
145        }
146    }
147}
148
149/// Fit diagnostics computed from the returned elements and encoded TLE.
150#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
151pub struct FitStatistics {
152    pub rms_position_km: f64,
153    pub max_position_km: f64,
154    pub rms_position_axes_km: [f64; 3],
155    pub rms_velocity_km_s: Option<f64>,
156    pub tle_rms_position_km: f64,
157    pub status: i32,
158    pub nfev: usize,
159    pub njev: usize,
160    pub cost: f64,
161    pub optimality: f64,
162    pub bstar_observable: bool,
163    pub seed_refine_passes: usize,
164}
165
166/// Fitted SGP4 elements plus compatibility encodings.
167///
168/// Cross-format epoch fidelity: `elements.epoch` is the exact split JD the
169/// fit solved at. `omm` carries it twice: as femtosecond-precision `EPOCH`
170/// calendar text, and through the in-memory `exact_sgp4_epoch` side channel
171/// (with `quantize_tle_derived_fields` disabled), so
172/// `Satellite::from_omm(&fit.omm)` is bit-identical to
173/// `Satellite::from_elements(&fit.elements)`. Encoding the OMM to text and
174/// reparsing rebuilds the epoch from the calendar form: the same instant to
175/// femtosecond precision, bit-identical when the sample epochs were
176/// midnight-anchored splits (see [`crate::astro::omm::Omm::exact_sgp4_epoch`]).
177/// The TLE lines are coarser by format: the TLE epoch field holds 8
178/// fractional day-of-year digits, a grid of about 0.86 ms, so `line1`/`line2`
179/// reproduce the fit epoch only to that resolution (reflected in
180/// `stats.tle_rms_position_km`).
181#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
182pub struct TleFit {
183    pub elements: ElementSet,
184    pub line1: String,
185    pub line2: String,
186    pub omm: Omm,
187    pub stats: FitStatistics,
188}
189
190/// Fitting failures.
191#[derive(Debug, Clone, PartialEq, Error)]
192pub enum TleFitError {
193    #[error("fit arc has {samples} samples; need at least {needed}")]
194    ArcTooShort { samples: usize, needed: usize },
195    #[error("fit input invalid: {field}: {reason}")]
196    InvalidInput {
197        field: &'static str,
198        reason: &'static str,
199    },
200    #[error("sample epochs must be strictly increasing (violation at index {index})")]
201    EpochsNotIncreasing { index: usize },
202    #[error("requested epoch lies outside the sample arc")]
203    EpochOutsideArc,
204    #[error("velocities must be present on every sample or on none")]
205    MixedVelocityPresence,
206    #[error("target arc is not elliptical (ecc >= 1 at the seed epoch)")]
207    NotElliptical,
208    #[error("seed inclination {inclination_deg} deg is too close to retrograde-equatorial")]
209    InclinationNearRetrograde { inclination_deg: f64 },
210    #[error("seed elements cannot propagate the arc at sample {epoch_index}: {source}")]
211    SeedPropagation {
212        epoch_index: usize,
213        source: Sgp4Error,
214    },
215    #[error("solver error: {0}")]
216    Solver(TrfError),
217    #[error("solver stopped at an infeasible point")]
218    SolutionInfeasible,
219    #[error("evaluation budget exhausted before convergence (best-effort result attached)")]
220    DidNotConverge { result: Box<TleFit> },
221    #[error("fitted elements failed final validation: {0}")]
222    FinalElements(Sgp4Error),
223    /// The final fitted TLE elements could not be encoded as TLE text.
224    #[error("fitted TLE failed encoding: {0}")]
225    TleEncode(tle::TleError),
226}
227
228/// Fit SGP4 mean elements and optional B* to a TEME ephemeris arc.
229pub fn fit_tle(samples: &[FitSample], config: &FitConfig) -> Result<TleFit, TleFitError> {
230    let resolved = validate_and_resolve(samples, config)?;
231    let (x0, seed_refine_passes) = initial_guess(samples, config, &resolved)?;
232    let seed_elements = chart_to_elements(
233        &x0,
234        resolved.epoch,
235        config.fit_bstar,
236        config.bstar_seed,
237        config.metadata.catalog_number,
238    )
239    .ok_or(TleFitError::NotElliptical)?;
240    validate_seed_propagates(samples, &seed_elements, config.opsmode)?;
241
242    let w_vel = config.velocity_weight_s.unwrap_or_else(|| {
243        let n_rad_s = x0[0] * TAU / SECONDS_PER_DAY;
244        1.0 / n_rad_s
245    });
246
247    let problem = Sgp4FitProblem {
248        samples,
249        epoch: resolved.epoch,
250        opsmode: config.opsmode,
251        fit_bstar: config.fit_bstar,
252        fixed_bstar: config.bstar_seed,
253        catalog_number: config.metadata.catalog_number,
254        weights: resolved.weights,
255        w_vel,
256        use_velocity: resolved.use_velocity,
257        penalty_hit_at_solution: Cell::new(false),
258    };
259
260    let options = solver_options(config);
261    let result = solve_model(&problem, &x0, &options).map_err(TleFitError::Solver)?;
262
263    let mut final_rows = Vec::new();
264    problem.penalty_hit_at_solution.set(false);
265    problem.residual(&result.x, &mut final_rows);
266    if problem.penalty_hit_at_solution.get() {
267        return Err(TleFitError::SolutionInfeasible);
268    }
269
270    let fit = build_fit(&problem, config, result, seed_refine_passes)?;
271    if fit.stats.status == 0 {
272        Err(TleFitError::DidNotConverge {
273            result: Box::new(fit),
274        })
275    } else {
276        Ok(fit)
277    }
278}
279
280struct ResolvedFit {
281    epoch: JulianDate,
282    epoch_index: usize,
283    use_velocity: bool,
284    weights: Vec<f64>,
285}
286
287struct Sgp4FitProblem<'a> {
288    samples: &'a [FitSample],
289    epoch: JulianDate,
290    opsmode: OpsMode,
291    fit_bstar: bool,
292    fixed_bstar: f64,
293    catalog_number: u32,
294    weights: Vec<f64>,
295    w_vel: f64,
296    use_velocity: bool,
297    penalty_hit_at_solution: Cell<bool>,
298}
299
300impl Sgp4FitProblem<'_> {
301    fn residual_len(&self) -> usize {
302        self.samples.len() * 3 * if self.use_velocity { 2 } else { 1 }
303    }
304
305    fn penalty(&self, out: &mut Vec<f64>) {
306        self.penalty_hit_at_solution.set(true);
307        out.clear();
308        out.resize(self.residual_len(), PENALTY_KM);
309    }
310}
311
312impl ResidualModel for Sgp4FitProblem<'_> {
313    fn residual(&self, x: &[f64], out: &mut Vec<f64>) {
314        let Some(elements) = chart_to_elements(
315            x,
316            self.epoch,
317            self.fit_bstar,
318            self.fixed_bstar,
319            self.catalog_number,
320        ) else {
321            self.penalty(out);
322            return;
323        };
324
325        let Ok(satellite) = Satellite::from_elements_with_opsmode(&elements, self.opsmode) else {
326            self.penalty(out);
327            return;
328        };
329
330        out.clear();
331        for (sample, &weight) in self.samples.iter().zip(&self.weights) {
332            let Ok(prediction) = satellite.propagate_jd(sample.epoch) else {
333                self.penalty(out);
334                return;
335            };
336            for axis in 0..3 {
337                out.push(weight * (prediction.position[axis] - sample.position_teme_km[axis]));
338            }
339            if self.use_velocity {
340                let velocity = sample
341                    .velocity_teme_km_s
342                    .expect("validated velocity presence");
343                for (axis, target) in velocity.iter().enumerate() {
344                    out.push(weight * self.w_vel * (prediction.velocity[axis] - *target));
345                }
346            }
347        }
348        self.penalty_hit_at_solution.set(false);
349    }
350}
351
352#[derive(Debug, Clone, Copy)]
353struct MeanChart {
354    n_rev_day: f64,
355    af: f64,
356    ag: f64,
357    chi: f64,
358    psi: f64,
359    lam: f64,
360}
361
362impl MeanChart {
363    fn to_vec(self, fit_bstar: bool, bstar: f64) -> Vec<f64> {
364        let mut x = vec![
365            self.n_rev_day,
366            self.af,
367            self.ag,
368            self.chi,
369            self.psi,
370            self.lam,
371        ];
372        if fit_bstar {
373            x.push(bstar / BSTAR_SCALE);
374        }
375        x
376    }
377
378    #[cfg(test)]
379    fn from_elements(elements: &ElementSet) -> Self {
380        let ecc = elements.eccentricity;
381        let argp = deg_to_rad(elements.argument_of_perigee_deg);
382        let raan = deg_to_rad(elements.right_ascension_deg);
383        let mean = deg_to_rad(elements.mean_anomaly_deg);
384        let incl = deg_to_rad(elements.inclination_deg);
385        let varpi = argp + raan;
386        let half_tan = (incl * 0.5).tan();
387        Self {
388            n_rev_day: elements.mean_motion_rev_per_day,
389            af: ecc * varpi.cos(),
390            ag: ecc * varpi.sin(),
391            chi: half_tan * raan.sin(),
392            psi: half_tan * raan.cos(),
393            lam: normalize_angle(mean + varpi),
394        }
395    }
396}
397
398fn validate_and_resolve(
399    samples: &[FitSample],
400    config: &FitConfig,
401) -> Result<ResolvedFit, TleFitError> {
402    if samples.len() < 3 {
403        return Err(TleFitError::ArcTooShort {
404            samples: samples.len(),
405            needed: 3,
406        });
407    }
408
409    for (i, sample) in samples.iter().enumerate() {
410        validate_epoch(sample.epoch)?;
411        if i > 0 && seconds_between(sample.epoch, samples[i - 1].epoch) <= 0.0 {
412            return Err(TleFitError::EpochsNotIncreasing { index: i });
413        }
414        validate_vec3("position_teme_km", &sample.position_teme_km)?;
415        if vec3::norm3(sample.position_teme_km) <= 0.0 {
416            return Err(TleFitError::InvalidInput {
417                field: "position_teme_km",
418                reason: "magnitude must be positive",
419            });
420        }
421        if let Some(velocity) = &sample.velocity_teme_km_s {
422            validate_vec3("velocity_teme_km_s", velocity)?;
423        }
424    }
425
426    let velocity_count = samples
427        .iter()
428        .filter(|sample| sample.velocity_teme_km_s.is_some())
429        .count();
430    let use_velocity = if config.use_velocity {
431        if velocity_count == 0 {
432            false
433        } else if velocity_count == samples.len() {
434            true
435        } else {
436            return Err(TleFitError::MixedVelocityPresence);
437        }
438    } else {
439        false
440    };
441
442    let n_params = 6 + usize::from(config.fit_bstar);
443    let rows_per_sample = 3 * if use_velocity { 2 } else { 1 };
444    if samples.len() * rows_per_sample < n_params {
445        let needed = n_params.div_ceil(rows_per_sample).max(3);
446        return Err(TleFitError::ArcTooShort {
447            samples: samples.len(),
448            needed,
449        });
450    }
451
452    let weights = if let Some(weights) = &config.weights {
453        if weights.len() != samples.len() {
454            return Err(TleFitError::InvalidInput {
455                field: "weights",
456                reason: "length must match samples",
457            });
458        }
459        for &weight in weights {
460            validate_positive("weights", weight)?;
461        }
462        weights.clone()
463    } else {
464        vec![1.0; samples.len()]
465    };
466
467    validate_optional_positive("velocity_weight_s", config.velocity_weight_s)?;
468    validate_finite("bstar_seed", config.bstar_seed)?;
469    validate_optional_positive("ftol", config.ftol)?;
470    validate_optional_positive("xtol", config.xtol)?;
471    validate_optional_positive("gtol", config.gtol)?;
472    validate_positive("f_scale", config.f_scale)?;
473    if config.max_nfev == Some(0) {
474        return Err(TleFitError::InvalidInput {
475            field: "max_nfev",
476            reason: "must be positive",
477        });
478    }
479    if config.metadata.catalog_number > 99_999 {
480        return Err(TleFitError::InvalidInput {
481            field: "metadata.catalog_number",
482            reason: "must be <= 99999",
483        });
484    }
485    if !matches!(config.metadata.classification.as_str(), "U" | "C" | "S") {
486        return Err(TleFitError::InvalidInput {
487            field: "metadata.classification",
488            reason: "must be U, C, or S",
489        });
490    }
491    if i32::try_from(config.metadata.rev_at_epoch).is_err() {
492        return Err(TleFitError::InvalidInput {
493            field: "metadata.rev_at_epoch",
494            reason: "must fit i32",
495        });
496    }
497
498    if let Some(XScale::Values(values)) = &config.x_scale {
499        if values.len() != n_params {
500            return Err(TleFitError::InvalidInput {
501                field: "x_scale",
502                reason: "length must match parameter count",
503            });
504        }
505        for &value in values {
506            validate_positive("x_scale", value)?;
507        }
508    }
509
510    let (epoch, epoch_index) = resolve_epoch(samples, config.epoch)?;
511    for sample in samples {
512        let minutes = seconds_between(sample.epoch, epoch).abs() / 60.0;
513        if minutes > MAX_MINUTES_SINCE_EPOCH {
514            return Err(TleFitError::InvalidInput {
515                field: "arc_span",
516                reason: "outside SGP4 time domain",
517            });
518        }
519    }
520
521    Ok(ResolvedFit {
522        epoch,
523        epoch_index,
524        use_velocity,
525        weights,
526    })
527}
528
529fn initial_guess(
530    samples: &[FitSample],
531    config: &FitConfig,
532    resolved: &ResolvedFit,
533) -> Result<(Vec<f64>, usize), TleFitError> {
534    let sample = samples[resolved.epoch_index];
535    let velocity = sample
536        .velocity_teme_km_s
537        .unwrap_or_else(|| finite_difference_velocity(samples, resolved.epoch_index));
538    let mut target = chart_from_state(sample.position_teme_km, velocity)?;
539    let dt = seconds_between(resolved.epoch, sample.epoch);
540    target.lam = normalize_angle(target.lam + target.n_rev_day * TAU / SECONDS_PER_DAY * dt);
541
542    if rad_to_deg(2.0 * (target.chi.hypot(target.psi)).atan()) >= 179.5 {
543        return Err(TleFitError::InclinationNearRetrograde {
544            inclination_deg: rad_to_deg(2.0 * (target.chi.hypot(target.psi)).atan()),
545        });
546    }
547
548    let x0 = target.to_vec(config.fit_bstar, config.bstar_seed);
549    let (x, passes) = refine_seed(
550        x0,
551        target,
552        resolved.epoch,
553        config.opsmode,
554        config.fit_bstar,
555        config.bstar_seed,
556        config.metadata.catalog_number,
557    );
558    Ok((x, passes))
559}
560
561fn refine_seed(
562    mut x: Vec<f64>,
563    target: MeanChart,
564    epoch: JulianDate,
565    opsmode: OpsMode,
566    fit_bstar: bool,
567    fixed_bstar: f64,
568    catalog_number: u32,
569) -> (Vec<f64>, usize) {
570    let mut passes = 0;
571    let mut previous_norm = f64::INFINITY;
572    for _ in 0..SEED_REFINE_PASSES {
573        let Some(elements) = chart_to_elements(&x, epoch, fit_bstar, fixed_bstar, catalog_number)
574        else {
575            break;
576        };
577        let Ok(satellite) = Satellite::from_elements_with_opsmode(&elements, opsmode) else {
578            break;
579        };
580        let Ok(prediction) = satellite.propagate_jd(epoch) else {
581            break;
582        };
583        let Ok(osc) = chart_from_state(prediction.position, prediction.velocity) else {
584            break;
585        };
586
587        let correction = [
588            target.n_rev_day - osc.n_rev_day,
589            target.af - osc.af,
590            target.ag - osc.ag,
591            target.chi - osc.chi,
592            target.psi - osc.psi,
593            wrap_to_pi(target.lam - osc.lam),
594        ];
595        let correction_norm = correction.iter().map(|v| v * v).sum::<f64>().sqrt();
596        if correction_norm > previous_norm {
597            break;
598        }
599
600        let mut candidate = x.clone();
601        for i in 0..6 {
602            candidate[i] += correction[i];
603        }
604        if chart_to_elements(&candidate, epoch, fit_bstar, fixed_bstar, catalog_number).is_none() {
605            break;
606        }
607
608        x = candidate;
609        previous_norm = correction_norm;
610        passes += 1;
611    }
612    (x, passes)
613}
614
615fn validate_seed_propagates(
616    samples: &[FitSample],
617    elements: &ElementSet,
618    opsmode: OpsMode,
619) -> Result<(), TleFitError> {
620    let satellite = Satellite::from_elements_with_opsmode(elements, opsmode).map_err(|source| {
621        TleFitError::SeedPropagation {
622            epoch_index: 0,
623            source,
624        }
625    })?;
626    for (i, sample) in samples.iter().enumerate() {
627        satellite
628            .propagate_jd(sample.epoch)
629            .map_err(|source| TleFitError::SeedPropagation {
630                epoch_index: i,
631                source,
632            })?;
633    }
634    Ok(())
635}
636
637fn build_fit(
638    problem: &Sgp4FitProblem<'_>,
639    config: &FitConfig,
640    result: TrfResult,
641    seed_refine_passes: usize,
642) -> Result<TleFit, TleFitError> {
643    let elements = chart_to_elements(
644        &result.x,
645        problem.epoch,
646        problem.fit_bstar,
647        problem.fixed_bstar,
648        problem.catalog_number,
649    )
650    .ok_or(TleFitError::SolutionInfeasible)?;
651    let satellite = Satellite::from_elements_with_opsmode(&elements, problem.opsmode)
652        .map_err(TleFitError::FinalElements)?;
653
654    let tle_elements = tle_elements_from_fit(&elements, &config.metadata)?;
655    let (line1, line2) = tle::encode(&tle_elements).map_err(TleFitError::TleEncode)?;
656    let tle_satellite = Satellite::from_tle_with_opsmode(&line1, &line2, problem.opsmode)
657        .map_err(TleFitError::FinalElements)?;
658
659    let arc_stats = compute_arc_stats(problem, &satellite).map_err(TleFitError::FinalElements)?;
660    let tle_rms_position_km =
661        compute_tle_rms(problem, &tle_satellite).map_err(TleFitError::FinalElements)?;
662    let bstar_observable = bstar_observable(&result, problem.fit_bstar);
663    let omm = omm_from_fit(&elements, &config.metadata)?;
664
665    Ok(TleFit {
666        elements,
667        line1,
668        line2,
669        omm,
670        stats: FitStatistics {
671            rms_position_km: arc_stats.rms_position_km,
672            max_position_km: arc_stats.max_position_km,
673            rms_position_axes_km: arc_stats.rms_position_axes_km,
674            rms_velocity_km_s: arc_stats.rms_velocity_km_s,
675            tle_rms_position_km,
676            status: result.status,
677            nfev: result.nfev,
678            njev: result.njev,
679            cost: result.cost,
680            optimality: result.optimality,
681            bstar_observable,
682            seed_refine_passes,
683        },
684    })
685}
686
687#[derive(Debug, Clone, Copy)]
688struct ArcStats {
689    rms_position_km: f64,
690    max_position_km: f64,
691    rms_position_axes_km: [f64; 3],
692    rms_velocity_km_s: Option<f64>,
693}
694
695fn compute_arc_stats(
696    problem: &Sgp4FitProblem<'_>,
697    satellite: &Satellite,
698) -> Result<ArcStats, Sgp4Error> {
699    let mut sum_r2 = 0.0;
700    let mut max_r = 0.0;
701    let mut axis_sum = [0.0; 3];
702    let mut sum_v2 = 0.0;
703    for sample in problem.samples {
704        let prediction = satellite.propagate_jd(sample.epoch)?;
705        let mut r2 = 0.0;
706        for (axis, axis_total) in axis_sum.iter_mut().enumerate() {
707            let residual = prediction.position[axis] - sample.position_teme_km[axis];
708            r2 += residual * residual;
709            *axis_total += residual * residual;
710        }
711        let r = r2.sqrt();
712        sum_r2 += r2;
713        if r > max_r {
714            max_r = r;
715        }
716
717        if problem.use_velocity {
718            let velocity = sample
719                .velocity_teme_km_s
720                .expect("validated velocity presence");
721            let mut v2 = 0.0;
722            for (pred, target) in prediction.velocity.iter().zip(velocity) {
723                let residual = pred - target;
724                v2 += residual * residual;
725            }
726            sum_v2 += v2;
727        }
728    }
729    let n = problem.samples.len() as f64;
730    Ok(ArcStats {
731        rms_position_km: (sum_r2 / n).sqrt(),
732        max_position_km: max_r,
733        rms_position_axes_km: [
734            (axis_sum[0] / n).sqrt(),
735            (axis_sum[1] / n).sqrt(),
736            (axis_sum[2] / n).sqrt(),
737        ],
738        rms_velocity_km_s: if problem.use_velocity {
739            Some((sum_v2 / n).sqrt())
740        } else {
741            None
742        },
743    })
744}
745
746fn compute_tle_rms(problem: &Sgp4FitProblem<'_>, satellite: &Satellite) -> Result<f64, Sgp4Error> {
747    let mut sum_r2 = 0.0;
748    for sample in problem.samples {
749        let prediction = satellite.propagate_jd(sample.epoch)?;
750        let mut r2 = 0.0;
751        for axis in 0..3 {
752            let residual = prediction.position[axis] - sample.position_teme_km[axis];
753            r2 += residual * residual;
754        }
755        sum_r2 += r2;
756    }
757    Ok((sum_r2 / problem.samples.len() as f64).sqrt())
758}
759
760fn bstar_observable(result: &TrfResult, fit_bstar: bool) -> bool {
761    if !fit_bstar {
762        return false;
763    }
764    let n = result.x.len();
765    if n == 0 || result.jac.is_empty() || !result.jac.len().is_multiple_of(n) {
766        return false;
767    }
768    let m = result.jac.len() / n;
769    let mut largest = 0.0_f64;
770    let mut bstar = 0.0_f64;
771    for col in 0..n {
772        let mut norm2 = 0.0;
773        for row in 0..m {
774            let value = result.jac[row * n + col];
775            norm2 += value * value;
776        }
777        let norm = norm2.sqrt();
778        if norm > largest {
779            largest = norm;
780        }
781        if col == n - 1 {
782            bstar = norm;
783        }
784    }
785    largest > 0.0 && bstar / largest >= BSTAR_OBSERVABLE_REL
786}
787
788fn chart_from_state(position: [f64; 3], velocity: [f64; 3]) -> Result<MeanChart, TleFitError> {
789    let coe =
790        rv2coe(position, velocity, MU_WGS72_KM3_S2).map_err(|_| TleFitError::NotElliptical)?;
791    chart_from_coe(&coe)
792}
793
794fn chart_from_coe(coe: &ClassicalElements) -> Result<MeanChart, TleFitError> {
795    if !coe.ecc.is_finite() || coe.ecc >= 1.0 || coe.ecc < 0.0 {
796        return Err(TleFitError::NotElliptical);
797    }
798    if !coe.a.is_finite() || coe.a <= 0.0 {
799        return Err(TleFitError::NotElliptical);
800    }
801
802    let (raan, argp, nu) = match coe.orbit_type {
803        OrbitType::EllipticalInclined => (coe.raan, coe.argp, coe.nu),
804        OrbitType::EllipticalEquatorial => (0.0, coe.lonper, coe.nu),
805        OrbitType::CircularInclined => (coe.raan, 0.0, coe.arglat),
806        OrbitType::CircularEquatorial => (0.0, 0.0, coe.truelon),
807    };
808    if !raan.is_finite() || !argp.is_finite() || !nu.is_finite() || !coe.incl.is_finite() {
809        return Err(TleFitError::NotElliptical);
810    }
811
812    let mean = true_to_mean(nu, coe.ecc).map_err(|_| TleFitError::NotElliptical)?;
813    let n_rad_s = (MU_WGS72_KM3_S2 / coe.a.powi(3)).sqrt();
814    let varpi = argp + raan;
815    let half_tan = (coe.incl * 0.5).tan();
816    Ok(MeanChart {
817        n_rev_day: n_rad_s * SECONDS_PER_DAY / TAU,
818        af: coe.ecc * varpi.cos(),
819        ag: coe.ecc * varpi.sin(),
820        chi: half_tan * raan.sin(),
821        psi: half_tan * raan.cos(),
822        lam: normalize_angle(mean + varpi),
823    })
824}
825
826fn chart_to_elements(
827    x: &[f64],
828    epoch: JulianDate,
829    fit_bstar: bool,
830    fixed_bstar: f64,
831    catalog_number: u32,
832) -> Option<ElementSet> {
833    let expected = 6 + usize::from(fit_bstar);
834    if x.len() != expected || x.iter().any(|v| !v.is_finite()) {
835        return None;
836    }
837    let n_rev_day = x[0];
838    let af = x[1];
839    let ag = x[2];
840    let chi = x[3];
841    let psi = x[4];
842    let lam = x[5];
843    let ecc = af.hypot(ag);
844    if n_rev_day <= 0.0 || ecc >= ECC_MAX {
845        return None;
846    }
847    let bstar = if fit_bstar {
848        x[6] * BSTAR_SCALE
849    } else {
850        fixed_bstar
851    };
852    if !bstar.is_finite() {
853        return None;
854    }
855
856    let varpi = ag.atan2(af);
857    let incl = 2.0 * chi.hypot(psi).atan();
858    let raan = chi.atan2(psi);
859    let argp = varpi - raan;
860    let mean = lam - varpi;
861
862    Some(ElementSet {
863        epoch,
864        bstar,
865        mean_motion_dot: 0.0,
866        mean_motion_double_dot: 0.0,
867        eccentricity: ecc,
868        argument_of_perigee_deg: normalize_degrees(rad_to_deg(argp)),
869        inclination_deg: rad_to_deg(incl),
870        mean_anomaly_deg: normalize_degrees(rad_to_deg(mean)),
871        mean_motion_rev_per_day: n_rev_day,
872        right_ascension_deg: normalize_degrees(rad_to_deg(raan)),
873        catalog_number,
874    })
875}
876
877fn finite_difference_velocity(samples: &[FitSample], index: usize) -> [f64; 3] {
878    let (lo, hi) = if index == 0 {
879        (0, 1)
880    } else if index + 1 == samples.len() {
881        (samples.len() - 2, samples.len() - 1)
882    } else {
883        (index - 1, index + 1)
884    };
885    let dt = seconds_between(samples[hi].epoch, samples[lo].epoch);
886    let mut velocity = [0.0; 3];
887    for (axis, value) in velocity.iter_mut().enumerate() {
888        *value = (samples[hi].position_teme_km[axis] - samples[lo].position_teme_km[axis]) / dt;
889    }
890    velocity
891}
892
893fn tle_elements_from_fit(
894    elements: &ElementSet,
895    metadata: &TleMetadata,
896) -> Result<TleElements, TleFitError> {
897    let (year, month, day, hour, minute, second) = civil_from_jd(elements.epoch);
898    let rev_number =
899        i32::try_from(metadata.rev_at_epoch).map_err(|_| TleFitError::InvalidInput {
900            field: "metadata.rev_at_epoch",
901            reason: "must fit i32",
902        })?;
903    Ok(TleElements {
904        catalog_number: format!("{:05}", metadata.catalog_number),
905        classification: metadata.classification.clone(),
906        international_designator: metadata.international_designator.clone(),
907        epoch_year: year as i32,
908        epoch_day_of_year: day_of_year(
909            year as i32,
910            month as i32,
911            day as i32,
912            hour as i32,
913            minute as i32,
914            second,
915        ),
916        mean_motion_dot: 0.0,
917        mean_motion_double_dot: 0.0,
918        bstar: elements.bstar,
919        ephemeris_type: 0,
920        elset_number: metadata.element_set_number,
921        inclination_deg: elements.inclination_deg,
922        raan_deg: elements.right_ascension_deg,
923        eccentricity: elements.eccentricity,
924        arg_perigee_deg: elements.argument_of_perigee_deg,
925        mean_anomaly_deg: elements.mean_anomaly_deg,
926        mean_motion: elements.mean_motion_rev_per_day,
927        rev_number,
928    })
929}
930
931fn omm_from_fit(elements: &ElementSet, metadata: &TleMetadata) -> Result<Omm, TleFitError> {
932    Ok(Omm {
933        ccsds_omm_vers: "2.0".to_string(),
934        creation_date: None,
935        originator: None,
936        object_name: Some(metadata.object_name.clone()),
937        object_id: object_id_from_designator(&metadata.international_designator),
938        center_name: Some("EARTH".to_string()),
939        ref_frame: Some("TEME".to_string()),
940        time_system: Some("UTC".to_string()),
941        mean_element_theory: Some("SGP4".to_string()),
942        epoch: crate::astro::omm::OmmEpoch::from_sgp4_julian_date(elements.epoch),
943        mean_motion: elements.mean_motion_rev_per_day,
944        eccentricity: elements.eccentricity,
945        inclination_deg: elements.inclination_deg,
946        ra_of_asc_node_deg: elements.right_ascension_deg,
947        arg_of_pericenter_deg: elements.argument_of_perigee_deg,
948        mean_anomaly_deg: elements.mean_anomaly_deg,
949        ephemeris_type: 0,
950        classification_type: metadata.classification.clone(),
951        norad_cat_id: metadata.catalog_number,
952        element_set_no: metadata.element_set_number,
953        rev_at_epoch: metadata.rev_at_epoch,
954        bstar: elements.bstar,
955        mean_motion_dot: 0.0,
956        mean_motion_ddot: 0.0,
957        exact_sgp4_epoch: Some(elements.epoch),
958        quantize_tle_derived_fields: false,
959    })
960}
961
962fn object_id_from_designator(designator: &str) -> Option<String> {
963    let trimmed = designator.trim();
964    if trimmed.is_empty() {
965        return None;
966    }
967    let bytes = trimmed.as_bytes();
968    if bytes.len() >= 5 && bytes[..5].iter().all(u8::is_ascii_digit) {
969        let yy: i32 = std::str::from_utf8(&bytes[..2]).ok()?.parse().ok()?;
970        let year = if yy < 57 { 2000 + yy } else { 1900 + yy };
971        let number = std::str::from_utf8(&bytes[2..5]).ok()?;
972        let piece = std::str::from_utf8(&bytes[5..]).ok()?;
973        Some(format!("{year:04}-{number}{piece}"))
974    } else {
975        Some(trimmed.to_string())
976    }
977}
978
979fn civil_from_jd(epoch: JulianDate) -> (i64, i64, i64, i64, i64, f64) {
980    let (midnight, fraction) = if (epoch.0.fract().abs() - 0.5).abs() < 1.0e-9 {
981        (epoch.0, epoch.1)
982    } else if epoch.1 >= 0.5 {
983        (epoch.0 + 0.5, epoch.1 - 0.5)
984    } else {
985        (epoch.0 - 0.5, epoch.1 + 0.5)
986    };
987    civil_from_split_julian_date(midnight, fraction)
988}
989
990fn solver_options(config: &FitConfig) -> TrfOptions {
991    let mut options = TrfOptions::default();
992    if let Some(ftol) = config.ftol {
993        options.ftol = ftol;
994    }
995    if let Some(xtol) = config.xtol {
996        options.xtol = xtol;
997    }
998    if let Some(gtol) = config.gtol {
999        options.gtol = gtol;
1000    }
1001    options.max_nfev = config.max_nfev;
1002    options.x_scale = config.x_scale.clone().unwrap_or(XScale::Jac);
1003    options.loss = config.loss;
1004    options.f_scale = config.f_scale;
1005    options
1006}
1007
1008fn resolve_epoch(
1009    samples: &[FitSample],
1010    epoch: FitEpoch,
1011) -> Result<(JulianDate, usize), TleFitError> {
1012    match epoch {
1013        FitEpoch::Midpoint => {
1014            let first = samples[0].epoch;
1015            let last = samples[samples.len() - 1].epoch;
1016            let mid_seconds = seconds_between(last, first) * 0.5;
1017            let target = add_seconds(first, mid_seconds);
1018            let index = nearest_sample(samples, target);
1019            Ok((samples[index].epoch, index))
1020        }
1021        FitEpoch::First => Ok((samples[0].epoch, 0)),
1022        FitEpoch::Last => {
1023            let index = samples.len() - 1;
1024            Ok((samples[index].epoch, index))
1025        }
1026        FitEpoch::Sample(index) => samples
1027            .get(index)
1028            .map(|sample| (sample.epoch, index))
1029            .ok_or(TleFitError::InvalidInput {
1030                field: "epoch",
1031                reason: "sample index out of bounds",
1032            }),
1033        FitEpoch::Jd(jd) => {
1034            validate_epoch(jd)?;
1035            if seconds_between(jd, samples[0].epoch) < 0.0
1036                || seconds_between(samples[samples.len() - 1].epoch, jd) < 0.0
1037            {
1038                return Err(TleFitError::EpochOutsideArc);
1039            }
1040            Ok((jd, nearest_sample(samples, jd)))
1041        }
1042    }
1043}
1044
1045fn nearest_sample(samples: &[FitSample], target: JulianDate) -> usize {
1046    let mut best = 0;
1047    let mut best_dt = seconds_between(samples[0].epoch, target).abs();
1048    for (i, sample) in samples.iter().enumerate().skip(1) {
1049        let dt = seconds_between(sample.epoch, target).abs();
1050        if dt < best_dt {
1051            best = i;
1052            best_dt = dt;
1053        }
1054    }
1055    best
1056}
1057
1058fn add_seconds(epoch: JulianDate, seconds: f64) -> JulianDate {
1059    let mut whole = epoch.0;
1060    let mut fraction = epoch.1 + seconds / SECONDS_PER_DAY;
1061    let carry = fraction.floor();
1062    whole += carry;
1063    fraction -= carry;
1064    JulianDate(whole, fraction)
1065}
1066
1067fn validate_epoch(epoch: JulianDate) -> Result<(), TleFitError> {
1068    validate_finite("epoch.whole", epoch.0)?;
1069    validate_finite("epoch.fraction", epoch.1)?;
1070    if !(0.0..1.0).contains(&epoch.1) {
1071        return Err(TleFitError::InvalidInput {
1072            field: "epoch.fraction",
1073            reason: "must be in [0, 1)",
1074        });
1075    }
1076    Ok(())
1077}
1078
1079fn validate_vec3(field: &'static str, values: &[f64; 3]) -> Result<(), TleFitError> {
1080    for value in values {
1081        validate_finite(field, *value)?;
1082    }
1083    Ok(())
1084}
1085
1086fn validate_optional_positive(field: &'static str, value: Option<f64>) -> Result<(), TleFitError> {
1087    if let Some(value) = value {
1088        validate_positive(field, value)?;
1089    }
1090    Ok(())
1091}
1092
1093fn validate_positive(field: &'static str, value: f64) -> Result<(), TleFitError> {
1094    validate_finite(field, value)?;
1095    if value <= 0.0 {
1096        return Err(TleFitError::InvalidInput {
1097            field,
1098            reason: "must be > 0",
1099        });
1100    }
1101    Ok(())
1102}
1103
1104fn validate_finite(field: &'static str, value: f64) -> Result<(), TleFitError> {
1105    if value.is_finite() {
1106        Ok(())
1107    } else {
1108        Err(TleFitError::InvalidInput {
1109            field,
1110            reason: "must be finite",
1111        })
1112    }
1113}
1114
1115fn seconds_between(later: JulianDate, earlier: JulianDate) -> f64 {
1116    ((later.0 - earlier.0) + (later.1 - earlier.1)) * SECONDS_PER_DAY
1117}
1118
1119#[cfg(test)]
1120fn deg_to_rad(deg: f64) -> f64 {
1121    deg * std::f64::consts::PI / 180.0
1122}
1123
1124fn rad_to_deg(rad: f64) -> f64 {
1125    rad * 180.0 / std::f64::consts::PI
1126}
1127
1128fn normalize_angle(angle: f64) -> f64 {
1129    let mut out = angle % TAU;
1130    if out < 0.0 {
1131        out += TAU;
1132    }
1133    out
1134}
1135
1136fn wrap_to_pi(angle: f64) -> f64 {
1137    let mut out = (angle + std::f64::consts::PI) % TAU;
1138    if out <= 0.0 {
1139        out += TAU;
1140    }
1141    out - std::f64::consts::PI
1142}
1143
1144fn normalize_degrees(degrees: f64) -> f64 {
1145    let mut out = degrees % 360.0;
1146    if out < 0.0 {
1147        out += 360.0;
1148    }
1149    out
1150}
1151
1152#[cfg(test)]
1153mod tests {
1154    use super::*;
1155    use crate::astro::frames::transforms::{gcrs_to_teme_compute, TemeStateKm};
1156    use crate::astro::omm::{encode_kvn, parse_kvn};
1157    use crate::astro::propagator::{propagate_states, PropagationConfig, PropagationForceModel};
1158    use crate::astro::time::civil::split_julian_date;
1159    use crate::astro::time::scales::TimeScales;
1160    use crate::astro::tle;
1161    use crate::constants::J2000_JD;
1162    use trust_region_least_squares::model::ResidualModel;
1163
1164    const ISS_L1: &str = "1 25544U 98067A   26168.18949189  .00009113  00000+0  17172-3 0  9996";
1165    const ISS_L2: &str = "2 25544  51.6332 300.0813 0004737 195.1146 164.9702 15.49273435571752";
1166    const GEO_L1: &str = "1 28884U 05041A   26167.71607684 -.00000267  00000+0  00000+0 0  9995";
1167    const GEO_L2: &str = "2 28884   3.5359  77.2731 0014354 137.8081 105.3728  0.98943614 75438";
1168    const DECAY_L1: &str = "1 28872U 05037B   05333.02012661  .25992681  00000-0  24476-3 0  1534";
1169    const DECAY_L2: &str = "2 28872  96.4736 157.9986 0303955 244.0492 110.6523 16.46015938 10708";
1170    const SSO_L1: &str = "1 28057U 03049A   06177.78615833  .00000060  00000-0  35970-4 0  1836";
1171    const SSO_L2: &str = "2 28057  98.4283 247.6961 0000884  88.1964 271.9322 14.35478080140550";
1172
1173    fn arc_from_tle(line1: &str, line2: &str, offsets_min: &[f64]) -> Vec<FitSample> {
1174        let satellite = Satellite::from_tle(line1, line2).expect("valid TLE");
1175        let epoch = satellite.epoch_jd();
1176        offsets_min
1177            .iter()
1178            .map(|&minutes| {
1179                let prediction = satellite
1180                    .propagate(super::super::MinutesSinceEpoch(minutes))
1181                    .expect("propagates");
1182                FitSample {
1183                    epoch: add_seconds(epoch, minutes * 60.0),
1184                    position_teme_km: prediction.position,
1185                    velocity_teme_km_s: Some(prediction.velocity),
1186                }
1187            })
1188            .collect()
1189    }
1190
1191    fn arc_from_elements(elements: &ElementSet, offsets_min: &[f64]) -> Vec<FitSample> {
1192        let satellite = Satellite::from_elements(elements).expect("valid elements");
1193        offsets_min
1194            .iter()
1195            .map(|&minutes| {
1196                let prediction = satellite
1197                    .propagate(super::super::MinutesSinceEpoch(minutes))
1198                    .expect("propagates");
1199                FitSample {
1200                    epoch: add_seconds(elements.epoch, minutes * 60.0),
1201                    position_teme_km: prediction.position,
1202                    velocity_teme_km_s: Some(prediction.velocity),
1203                }
1204            })
1205            .collect()
1206    }
1207
1208    fn metadata_from_tle(line1: &str, line2: &str) -> TleMetadata {
1209        let parsed = tle::parse(line1, line2).expect("parse").elements;
1210        TleMetadata {
1211            catalog_number: parsed.catalog_number.parse().unwrap(),
1212            classification: parsed.classification,
1213            international_designator: parsed.international_designator,
1214            element_set_number: parsed.elset_number,
1215            rev_at_epoch: parsed.rev_number as i64,
1216            object_name: String::new(),
1217        }
1218    }
1219
1220    fn metadata_for_catalog(catalog_number: u32) -> TleMetadata {
1221        TleMetadata {
1222            catalog_number,
1223            classification: "U".to_string(),
1224            international_designator: String::new(),
1225            element_set_number: 999,
1226            rev_at_epoch: 0,
1227            object_name: String::new(),
1228        }
1229    }
1230
1231    fn chart_delta(a: &ElementSet, b: &ElementSet) -> [f64; 6] {
1232        let ca = MeanChart::from_elements(a);
1233        let cb = MeanChart::from_elements(b);
1234        [
1235            ca.n_rev_day - cb.n_rev_day,
1236            ca.af - cb.af,
1237            ca.ag - cb.ag,
1238            ca.chi - cb.chi,
1239            ca.psi - cb.psi,
1240            wrap_to_pi(ca.lam - cb.lam),
1241        ]
1242    }
1243
1244    fn assert_element_sets_bit_identical(a: &ElementSet, b: &ElementSet) {
1245        assert_eq!(a.epoch.0.to_bits(), b.epoch.0.to_bits(), "epoch whole");
1246        assert_eq!(a.epoch.1.to_bits(), b.epoch.1.to_bits(), "epoch frac");
1247        assert_eq!(a.bstar.to_bits(), b.bstar.to_bits(), "bstar");
1248        assert_eq!(
1249            a.mean_motion_dot.to_bits(),
1250            b.mean_motion_dot.to_bits(),
1251            "mean motion dot"
1252        );
1253        assert_eq!(
1254            a.mean_motion_double_dot.to_bits(),
1255            b.mean_motion_double_dot.to_bits(),
1256            "mean motion ddot"
1257        );
1258        assert_eq!(a.eccentricity.to_bits(), b.eccentricity.to_bits(), "ecc");
1259        assert_eq!(
1260            a.argument_of_perigee_deg.to_bits(),
1261            b.argument_of_perigee_deg.to_bits(),
1262            "argp"
1263        );
1264        assert_eq!(
1265            a.inclination_deg.to_bits(),
1266            b.inclination_deg.to_bits(),
1267            "incl"
1268        );
1269        assert_eq!(
1270            a.mean_anomaly_deg.to_bits(),
1271            b.mean_anomaly_deg.to_bits(),
1272            "mean anomaly"
1273        );
1274        assert_eq!(
1275            a.mean_motion_rev_per_day.to_bits(),
1276            b.mean_motion_rev_per_day.to_bits(),
1277            "mean motion"
1278        );
1279        assert_eq!(
1280            a.right_ascension_deg.to_bits(),
1281            b.right_ascension_deg.to_bits(),
1282            "raan"
1283        );
1284        assert_eq!(a.catalog_number, b.catalog_number, "catalog");
1285    }
1286
1287    fn assert_satellites_bit_identical(a: &Satellite, b: &Satellite) {
1288        let ea = a.epoch_jd();
1289        let eb = b.epoch_jd();
1290        assert_eq!(ea.0.to_bits(), eb.0.to_bits(), "sat epoch whole");
1291        assert_eq!(ea.1.to_bits(), eb.1.to_bits(), "sat epoch fraction");
1292        for minutes in [-120.0, 0.0, 120.0] {
1293            let pa = a
1294                .propagate(super::super::MinutesSinceEpoch(minutes))
1295                .expect("left propagates");
1296            let pb = b
1297                .propagate(super::super::MinutesSinceEpoch(minutes))
1298                .expect("right propagates");
1299            for axis in 0..3 {
1300                assert_eq!(
1301                    pa.position[axis].to_bits(),
1302                    pb.position[axis].to_bits(),
1303                    "position axis {axis} at {minutes} min"
1304                );
1305                assert_eq!(
1306                    pa.velocity[axis].to_bits(),
1307                    pb.velocity[axis].to_bits(),
1308                    "velocity axis {axis} at {minutes} min"
1309                );
1310            }
1311        }
1312    }
1313
1314    fn assert_fit_bit_identical(a: &TleFit, b: &TleFit) {
1315        assert_element_sets_bit_identical(&a.elements, &b.elements);
1316        assert_eq!(a.line1, b.line1);
1317        assert_eq!(a.line2, b.line2);
1318        assert_eq!(a.omm, b.omm);
1319        assert_eq!(a.stats, b.stats);
1320    }
1321
1322    fn rms_against_samples(samples: &[FitSample], satellite: &Satellite) -> f64 {
1323        let mut sum = 0.0;
1324        for sample in samples {
1325            let prediction = satellite
1326                .propagate_jd(sample.epoch)
1327                .expect("sample propagates");
1328            let mut r2 = 0.0;
1329            for axis in 0..3 {
1330                let residual = prediction.position[axis] - sample.position_teme_km[axis];
1331                r2 += residual * residual;
1332            }
1333            sum += r2;
1334        }
1335        (sum / samples.len() as f64).sqrt()
1336    }
1337
1338    fn seed_rms(samples: &[FitSample], config: &FitConfig) -> f64 {
1339        let resolved = validate_and_resolve(samples, config).expect("valid fit input");
1340        let (x0, _) = initial_guess(samples, config, &resolved).expect("seed");
1341        let seed = chart_to_elements(
1342            &x0,
1343            resolved.epoch,
1344            config.fit_bstar,
1345            config.bstar_seed,
1346            config.metadata.catalog_number,
1347        )
1348        .expect("seed elements");
1349        let satellite =
1350            Satellite::from_elements_with_opsmode(&seed, config.opsmode).expect("seed satellite");
1351        rms_against_samples(samples, &satellite)
1352    }
1353
1354    fn clean_position_rms(samples: &[FitSample], satellite: &Satellite) -> f64 {
1355        rms_against_samples(samples, satellite)
1356    }
1357
1358    fn fit_round_trip_case(
1359        name: &str,
1360        truth: &ElementSet,
1361        samples: &[FitSample],
1362        max_rms_km: f64,
1363        max_chart_delta: f64,
1364    ) {
1365        let config = FitConfig {
1366            epoch: FitEpoch::Jd(truth.epoch),
1367            fit_bstar: false,
1368            metadata: metadata_for_catalog(truth.catalog_number),
1369            max_nfev: Some(120),
1370            ..FitConfig::default()
1371        };
1372        let fit = fit_tle(samples, &config).unwrap_or_else(|e| panic!("{name}: {e}"));
1373        assert!(
1374            fit.stats.rms_position_km <= max_rms_km,
1375            "{name} rms {}",
1376            fit.stats.rms_position_km
1377        );
1378        let delta = chart_delta(&fit.elements, truth);
1379        for value in delta {
1380            assert!(value.abs() <= max_chart_delta, "{name} chart delta {value}");
1381        }
1382    }
1383
1384    fn utc_time_scales_with_offset(offset_s: f64) -> TimeScales {
1385        let hour = (offset_s / 3600.0).floor() as i32;
1386        let rem = offset_s - hour as f64 * 3600.0;
1387        let minute = (rem / 60.0).floor() as i32;
1388        let second = rem - minute as f64 * 60.0;
1389        TimeScales::from_utc(2026, 6, 17, hour, minute, second).expect("time scales")
1390    }
1391
1392    fn numerical_teme_arc(offsets_s: &[f64], include_velocity: bool) -> Vec<FitSample> {
1393        let start = TimeScales::from_utc(2026, 6, 17, 0, 0, 0.0).expect("start time");
1394        let start_tdb_s = (start.jd_tdb - J2000_JD) * SECONDS_PER_DAY;
1395        let config = PropagationConfig {
1396            force_model: PropagationForceModel::TwoBodyJ2,
1397            ..PropagationConfig::new(start_tdb_s, [7000.0, -1210.0, 1300.0], [1.2, 7.35, 0.92])
1398        };
1399        let epochs: Vec<f64> = offsets_s
1400            .iter()
1401            .map(|offset| start_tdb_s + offset)
1402            .collect();
1403        let states = propagate_states(&config, &epochs).expect("numerical propagation");
1404        states
1405            .iter()
1406            .zip(offsets_s)
1407            .map(|(state, &offset_s)| {
1408                let ts = utc_time_scales_with_offset(offset_s);
1409                let gcrs = TemeStateKm {
1410                    position_km: state.position_array(),
1411                    velocity_km_s: state.velocity_array(),
1412                };
1413                let (position, velocity) =
1414                    gcrs_to_teme_compute(&gcrs, &ts, false).expect("GCRS to TEME");
1415                let (jd_whole, fraction) = split_julian_date(2026, 6, 17, 0, 0, offset_s);
1416                FitSample {
1417                    epoch: JulianDate(jd_whole, fraction),
1418                    position_teme_km: [position.0, position.1, position.2],
1419                    velocity_teme_km_s: include_velocity
1420                        .then_some([velocity.0, velocity.1, velocity.2]),
1421                }
1422            })
1423            .collect()
1424    }
1425
1426    #[test]
1427    fn chart_round_trips_degenerate_angles() {
1428        let cases = [
1429            ElementSet {
1430                epoch: JulianDate(2_460_000.5, 0.0),
1431                bstar: 0.0,
1432                mean_motion_dot: 0.0,
1433                mean_motion_double_dot: 0.0,
1434                eccentricity: 0.0,
1435                argument_of_perigee_deg: 0.0,
1436                inclination_deg: 0.0,
1437                mean_anomaly_deg: 42.0,
1438                mean_motion_rev_per_day: 1.0027,
1439                right_ascension_deg: 0.0,
1440                catalog_number: 1,
1441            },
1442            tle::parse(ISS_L1, ISS_L2)
1443                .unwrap()
1444                .elements
1445                .to_element_set()
1446                .unwrap(),
1447            tle::parse(GEO_L1, GEO_L2)
1448                .unwrap()
1449                .elements
1450                .to_element_set()
1451                .unwrap(),
1452        ];
1453
1454        for elements in cases {
1455            let chart = MeanChart::from_elements(&elements).to_vec(true, elements.bstar);
1456            let round =
1457                chart_to_elements(&chart, elements.epoch, true, 0.0, elements.catalog_number)
1458                    .expect("chart domain");
1459            let delta = chart_delta(&elements, &round);
1460            for value in delta {
1461                assert!(value.abs() <= 1.0e-12, "delta {value}");
1462            }
1463            assert!((elements.bstar - round.bstar).abs() <= 1.0e-15);
1464        }
1465    }
1466
1467    #[test]
1468    fn penalty_path_keeps_residual_length() {
1469        let samples = arc_from_tle(ISS_L1, ISS_L2, &[-20.0, 0.0, 20.0]);
1470        let problem = Sgp4FitProblem {
1471            samples: &samples,
1472            epoch: samples[1].epoch,
1473            opsmode: OpsMode::Improved,
1474            fit_bstar: true,
1475            fixed_bstar: 0.0,
1476            catalog_number: 25544,
1477            weights: vec![1.0; samples.len()],
1478            w_vel: 900.0,
1479            use_velocity: true,
1480            penalty_hit_at_solution: Cell::new(false),
1481        };
1482        let mut out = Vec::new();
1483        problem.residual(&[15.0, ECC_MAX, 0.0, 0.0, 0.0, 0.0, 0.0], &mut out);
1484        assert_eq!(out.len(), samples.len() * 6);
1485        assert!(out.iter().all(|&value| value == PENALTY_KM));
1486        assert!(problem.penalty_hit_at_solution.get());
1487    }
1488
1489    #[test]
1490    fn round_trip_recovers_iss_elements() {
1491        let offsets: Vec<f64> = (-36..=36).map(|i| i as f64 * 10.0).collect();
1492        let samples = arc_from_tle(ISS_L1, ISS_L2, &offsets);
1493        let truth = tle::parse(ISS_L1, ISS_L2)
1494            .unwrap()
1495            .elements
1496            .to_element_set()
1497            .unwrap();
1498        let config = FitConfig {
1499            epoch: FitEpoch::Jd(truth.epoch),
1500            metadata: metadata_from_tle(ISS_L1, ISS_L2),
1501            max_nfev: Some(80),
1502            ..FitConfig::default()
1503        };
1504
1505        let fit = fit_tle(&samples, &config).expect("fit converges");
1506        assert!(fit.stats.status >= 1);
1507        assert!(
1508            fit.stats.rms_position_km <= 1.0e-3,
1509            "rms {}",
1510            fit.stats.rms_position_km
1511        );
1512        let delta = chart_delta(&fit.elements, &truth);
1513        assert!(delta[0].abs() <= 1.0e-8, "n {}", delta[0]);
1514        for value in &delta[1..5] {
1515            assert!(value.abs() <= 1.0e-7, "chart {value}");
1516        }
1517        assert!(delta[5].abs() <= 1.0e-6, "lam {}", delta[5]);
1518        assert!(fit.stats.tle_rms_position_km <= 0.1);
1519        assert_eq!(fit.elements.mean_motion_dot, 0.0);
1520        assert_eq!(fit.elements.mean_motion_double_dot, 0.0);
1521        assert_eq!(fit.omm.mean_motion_dot, 0.0);
1522        assert_eq!(fit.omm.mean_motion_ddot, 0.0);
1523
1524        let omm_elements = fit.omm.to_element_set().expect("fitted OMM bridge");
1525        assert_element_sets_bit_identical(&omm_elements, &fit.elements);
1526        let from_omm = Satellite::from_omm(&fit.omm).expect("fitted OMM satellite");
1527        let from_elements =
1528            Satellite::from_elements(&fit.elements).expect("fitted element satellite");
1529        assert_satellites_bit_identical(&from_omm, &from_elements);
1530
1531        let encoded_omm = encode_kvn(&fit.omm);
1532        let reparsed_omm = parse_kvn(&encoded_omm).expect("encoded fitted OMM reparses");
1533        let _ = reparsed_omm
1534            .to_element_set()
1535            .expect("encoded fitted OMM bridges");
1536    }
1537
1538    #[test]
1539    fn geo_short_arc_marks_bstar_unobservable() {
1540        let offsets: Vec<f64> = (-12..=12).map(|i| i as f64 * 30.0).collect();
1541        let samples = arc_from_tle(GEO_L1, GEO_L2, &offsets);
1542        let truth = tle::parse(GEO_L1, GEO_L2)
1543            .unwrap()
1544            .elements
1545            .to_element_set()
1546            .unwrap();
1547        let config = FitConfig {
1548            epoch: FitEpoch::Jd(truth.epoch),
1549            metadata: metadata_from_tle(GEO_L1, GEO_L2),
1550            max_nfev: Some(80),
1551            ..FitConfig::default()
1552        };
1553
1554        let fit = fit_tle(&samples, &config).expect("fit converges");
1555        assert!(fit.stats.rms_position_km <= 1.0e-3);
1556        assert!(!fit.stats.bstar_observable);
1557    }
1558
1559    #[test]
1560    fn high_drag_bstar_recovery_is_observable() {
1561        let offsets: Vec<f64> = (0..=10).map(|i| i as f64 * 5.0).collect();
1562        let samples = arc_from_tle(DECAY_L1, DECAY_L2, &offsets);
1563        let truth = tle::parse(DECAY_L1, DECAY_L2)
1564            .unwrap()
1565            .elements
1566            .to_element_set()
1567            .unwrap();
1568        let config = FitConfig {
1569            epoch: FitEpoch::Jd(truth.epoch),
1570            metadata: metadata_from_tle(DECAY_L1, DECAY_L2),
1571            max_nfev: Some(120),
1572            ..FitConfig::default()
1573        };
1574
1575        let fit = fit_tle(&samples, &config).expect("high-drag fit converges");
1576        assert!(fit.stats.bstar_observable);
1577        assert!(
1578            (fit.elements.bstar - truth.bstar).abs() <= 1.0e-6,
1579            "bstar fit {} truth {}",
1580            fit.elements.bstar,
1581            truth.bstar
1582        );
1583    }
1584
1585    #[test]
1586    fn fitted_omm_epoch_survives_text_round_trip() {
1587        let truth = tle::parse(ISS_L1, ISS_L2)
1588            .unwrap()
1589            .elements
1590            .to_element_set()
1591            .unwrap();
1592        let metadata = metadata_from_tle(ISS_L1, ISS_L2);
1593
1594        // Midnight-anchored split (what calendar/TLE producers emit): the
1595        // femtosecond epoch text carries the full split JD, so a text round
1596        // trip rebuilds it bit-identically and propagation matches exactly.
1597        let omm = omm_from_fit(&truth, &metadata).expect("fitted OMM");
1598        let mut reparsed = parse_kvn(&encode_kvn(&omm)).expect("fitted OMM reparses");
1599        assert_eq!(reparsed.epoch, omm.epoch);
1600        assert_eq!(reparsed.exact_sgp4_epoch, None);
1601        // The quantize flag is in-memory fit state, not carried by the text.
1602        reparsed.quantize_tle_derived_fields = false;
1603
1604        let in_memory_elements = omm.to_element_set().expect("in-memory bridge");
1605        let rebuilt = reparsed.to_element_set().expect("reparsed OMM bridges");
1606        assert_element_sets_bit_identical(&rebuilt, &in_memory_elements);
1607
1608        let direct = Satellite::from_omm(&omm).expect("direct satellite");
1609        let round_tripped = Satellite::from_omm(&reparsed).expect("round-tripped satellite");
1610        for minutes in [0.0, 45.0, 720.0] {
1611            let jd = add_seconds(truth.epoch, minutes * 60.0);
1612            let a = direct.propagate_jd(jd).expect("direct propagates");
1613            let b = round_tripped.propagate_jd(jd).expect("reparsed propagates");
1614            assert_eq!(a.position, b.position, "position at {minutes} min");
1615            assert_eq!(a.velocity, b.velocity, "velocity at {minutes} min");
1616        }
1617
1618        // Non-canonical (noon-anchored) split: the side channel preserves the
1619        // producer's representation in memory, while the text round trip
1620        // renormalizes to the midnight-anchored split of the same instant.
1621        let JulianDate(whole, fraction) = truth.epoch;
1622        let shifted = if fraction >= 0.5 {
1623            JulianDate(whole + 0.5, fraction - 0.5)
1624        } else {
1625            JulianDate(whole - 0.5, fraction + 0.5)
1626        };
1627        assert_eq!(shifted.0 + shifted.1, whole + fraction);
1628        let mut shifted_elements = truth.clone();
1629        shifted_elements.epoch = shifted;
1630        let omm2 = omm_from_fit(&shifted_elements, &metadata).expect("shifted fitted OMM");
1631        let in_memory = omm2.to_element_set().expect("in-memory bridge").epoch;
1632        assert_eq!(in_memory.0.to_bits(), shifted.0.to_bits());
1633        assert_eq!(in_memory.1.to_bits(), shifted.1.to_bits());
1634
1635        let mut reparsed2 = parse_kvn(&encode_kvn(&omm2)).expect("shifted OMM reparses");
1636        reparsed2.quantize_tle_derived_fields = false;
1637        let rebuilt2 = reparsed2.to_element_set().expect("shifted bridge").epoch;
1638        assert_eq!(rebuilt2.0.to_bits(), whole.to_bits());
1639        assert_eq!(rebuilt2.1.to_bits(), fraction.to_bits());
1640        assert_eq!(rebuilt2.0 + rebuilt2.1, shifted.0 + shifted.1);
1641
1642        // Propagation error bound between the in-memory representation and
1643        // the renormalized text round trip: the instant is exact, only the
1644        // split representation differs, so any drift stays at the last-ULP
1645        // tsince level (well under a micrometer).
1646        let in_memory_sat = Satellite::from_omm(&omm2).expect("in-memory satellite");
1647        let round_tripped2 = Satellite::from_omm(&reparsed2).expect("round-tripped satellite");
1648        for minutes in [0.0, 45.0, 720.0] {
1649            let jd = add_seconds(truth.epoch, minutes * 60.0);
1650            let a = in_memory_sat
1651                .propagate_jd(jd)
1652                .expect("in-memory propagates");
1653            let b = round_tripped2
1654                .propagate_jd(jd)
1655                .expect("reparsed propagates");
1656            for axis in 0..3 {
1657                assert!(
1658                    (a.position[axis] - b.position[axis]).abs() <= 1.0e-9,
1659                    "axis {axis} at {minutes} min: {} vs {}",
1660                    a.position[axis],
1661                    b.position[axis]
1662                );
1663            }
1664        }
1665    }
1666
1667    #[test]
1668    fn molniya_and_sun_sync_round_trip_cases_converge() {
1669        let molniya = ElementSet {
1670            epoch: JulianDate(2_461_208.5, 0.317_123_456_789_012),
1671            bstar: 0.0,
1672            mean_motion_dot: 0.0,
1673            mean_motion_double_dot: 0.0,
1674            eccentricity: 0.742_8,
1675            argument_of_perigee_deg: 270.5,
1676            inclination_deg: 63.4,
1677            mean_anomaly_deg: 15.8,
1678            mean_motion_rev_per_day: 2.006_1,
1679            right_ascension_deg: 90.16,
1680            catalog_number: 28163,
1681        };
1682        let molniya_offsets: Vec<f64> = (-12..=12).map(|i| i as f64 * 120.0).collect();
1683        let molniya_samples = arc_from_elements(&molniya, &molniya_offsets);
1684        fit_round_trip_case("molniya", &molniya, &molniya_samples, 1.0e-3, 2.0e-6);
1685
1686        let sso_truth = tle::parse(SSO_L1, SSO_L2)
1687            .unwrap()
1688            .elements
1689            .to_element_set()
1690            .unwrap();
1691        let sso_offsets: Vec<f64> = (-24..=24).map(|i| i as f64 * 10.0).collect();
1692        let sso_samples = arc_from_tle(SSO_L1, SSO_L2, &sso_offsets);
1693        fit_round_trip_case("sun-sync", &sso_truth, &sso_samples, 1.0e-3, 2.0e-6);
1694    }
1695
1696    #[test]
1697    fn raan_singular_geo_round_trip_converges() {
1698        let truth = ElementSet {
1699            epoch: JulianDate(2_461_208.5, 0.625_987_654_321_098),
1700            bstar: 0.0,
1701            mean_motion_dot: 0.0,
1702            mean_motion_double_dot: 0.0,
1703            eccentricity: 0.0012,
1704            argument_of_perigee_deg: 137.8,
1705            inclination_deg: 0.05,
1706            mean_anomaly_deg: 105.4,
1707            mean_motion_rev_per_day: 1.0027,
1708            right_ascension_deg: 77.3,
1709            catalog_number: 39000,
1710        };
1711        let offsets: Vec<f64> = (-12..=12).map(|i| i as f64 * 120.0).collect();
1712        let samples = arc_from_elements(&truth, &offsets);
1713        fit_round_trip_case("raan-singular geo", &truth, &samples, 1.0e-3, 2.0e-6);
1714    }
1715
1716    #[test]
1717    fn numerical_cross_source_oracle_improves_seed_position_only() {
1718        let offsets_s: Vec<f64> = (0..=18).map(|i| i as f64 * 300.0).collect();
1719        let samples = numerical_teme_arc(&offsets_s, false);
1720        let config = FitConfig {
1721            epoch: FitEpoch::Midpoint,
1722            fit_bstar: false,
1723            use_velocity: false,
1724            metadata: metadata_for_catalog(60001),
1725            max_nfev: Some(120),
1726            ..FitConfig::default()
1727        };
1728
1729        let seed = seed_rms(&samples, &config);
1730        let fit = fit_tle(&samples, &config).expect("numerical position fit");
1731        assert!(
1732            fit.stats.rms_position_km < seed,
1733            "fit rms {} seed rms {}",
1734            fit.stats.rms_position_km,
1735            seed
1736        );
1737        assert!(!fit.stats.bstar_observable);
1738    }
1739
1740    #[test]
1741    fn numerical_cross_source_oracle_improves_seed_with_velocity() {
1742        let offsets_s: Vec<f64> = (0..=18).map(|i| i as f64 * 300.0).collect();
1743        let samples = numerical_teme_arc(&offsets_s, true);
1744        let config = FitConfig {
1745            epoch: FitEpoch::Midpoint,
1746            fit_bstar: false,
1747            use_velocity: true,
1748            metadata: metadata_for_catalog(60002),
1749            max_nfev: Some(120),
1750            ..FitConfig::default()
1751        };
1752
1753        let seed = seed_rms(&samples, &config);
1754        let fit = fit_tle(&samples, &config).expect("numerical position+velocity fit");
1755        assert!(
1756            fit.stats.rms_position_km < seed,
1757            "fit rms {} seed rms {}",
1758            fit.stats.rms_position_km,
1759            seed
1760        );
1761        assert!(fit.stats.rms_velocity_km_s.is_some());
1762        assert!(!fit.stats.bstar_observable);
1763    }
1764
1765    #[test]
1766    fn seed_refinement_improves_initial_guess() {
1767        let samples = arc_from_tle(ISS_L1, ISS_L2, &[-30.0, -15.0, 0.0, 15.0, 30.0]);
1768        let config = FitConfig {
1769            epoch: FitEpoch::Sample(2),
1770            metadata: metadata_from_tle(ISS_L1, ISS_L2),
1771            ..FitConfig::default()
1772        };
1773        let resolved = validate_and_resolve(&samples, &config).expect("valid fit input");
1774        let sample = samples[resolved.epoch_index];
1775        let target = chart_from_state(sample.position_teme_km, sample.velocity_teme_km_s.unwrap())
1776            .expect("target chart");
1777        let raw = target.to_vec(config.fit_bstar, config.bstar_seed);
1778        let raw_elements = chart_to_elements(
1779            &raw,
1780            resolved.epoch,
1781            config.fit_bstar,
1782            config.bstar_seed,
1783            config.metadata.catalog_number,
1784        )
1785        .expect("raw seed");
1786        let raw_satellite = Satellite::from_elements(&raw_elements).expect("raw seed satellite");
1787        let raw_rms = rms_against_samples(&samples, &raw_satellite);
1788
1789        let (refined, passes) = refine_seed(
1790            raw,
1791            target,
1792            resolved.epoch,
1793            config.opsmode,
1794            config.fit_bstar,
1795            config.bstar_seed,
1796            config.metadata.catalog_number,
1797        );
1798        let refined_elements = chart_to_elements(
1799            &refined,
1800            resolved.epoch,
1801            config.fit_bstar,
1802            config.bstar_seed,
1803            config.metadata.catalog_number,
1804        )
1805        .expect("refined seed");
1806        let refined_satellite =
1807            Satellite::from_elements(&refined_elements).expect("refined seed satellite");
1808        let refined_rms = rms_against_samples(&samples, &refined_satellite);
1809
1810        assert!(passes > 0);
1811        assert!(
1812            refined_rms < raw_rms,
1813            "refined rms {refined_rms} raw rms {raw_rms}"
1814        );
1815    }
1816
1817    #[test]
1818    fn seed_propagation_reports_typed_error() {
1819        let elements = tle::parse(DECAY_L1, DECAY_L2)
1820            .unwrap()
1821            .elements
1822            .to_element_set()
1823            .unwrap();
1824        let samples = [
1825            FitSample {
1826                epoch: elements.epoch,
1827                position_teme_km: [7000.0, 0.0, 0.0],
1828                velocity_teme_km_s: Some([0.0, 7.5, 0.0]),
1829            },
1830            FitSample {
1831                epoch: add_seconds(elements.epoch, 1440.0 * 60.0),
1832                position_teme_km: [7000.0, 0.0, 0.0],
1833                velocity_teme_km_s: Some([0.0, 7.5, 0.0]),
1834            },
1835        ];
1836        let err = validate_seed_propagates(&samples, &elements, OpsMode::Improved)
1837            .expect_err("decayed seed must error");
1838        assert!(matches!(
1839            err,
1840            TleFitError::SeedPropagation { epoch_index: 1, .. }
1841        ));
1842    }
1843
1844    #[test]
1845    fn epoch_selection_modes_resolve_expected_epochs() {
1846        let samples = arc_from_tle(ISS_L1, ISS_L2, &[-20.0, -10.0, 0.0, 10.0, 20.0]);
1847        for (mode, expected_index) in [
1848            (FitEpoch::First, 0),
1849            (FitEpoch::Midpoint, 2),
1850            (FitEpoch::Last, 4),
1851            (FitEpoch::Sample(3), 3),
1852        ] {
1853            let config = FitConfig {
1854                epoch: mode,
1855                ..FitConfig::default()
1856            };
1857            let resolved = validate_and_resolve(&samples, &config).expect("resolve epoch");
1858            assert_eq!(resolved.epoch_index, expected_index);
1859            assert_eq!(resolved.epoch, samples[expected_index].epoch);
1860        }
1861
1862        let explicit = add_seconds(samples[0].epoch, 15.0 * 60.0);
1863        let config = FitConfig {
1864            epoch: FitEpoch::Jd(explicit),
1865            ..FitConfig::default()
1866        };
1867        let resolved = validate_and_resolve(&samples, &config).expect("resolve JD epoch");
1868        assert_eq!(resolved.epoch, explicit);
1869        assert_eq!(resolved.epoch_index, 1);
1870
1871        let outside = add_seconds(samples[0].epoch, -1.0);
1872        let config = FitConfig {
1873            epoch: FitEpoch::Jd(outside),
1874            ..FitConfig::default()
1875        };
1876        assert!(matches!(
1877            validate_and_resolve(&samples, &config),
1878            Err(TleFitError::EpochOutsideArc)
1879        ));
1880    }
1881
1882    #[test]
1883    fn sample_weights_reduce_outlier_leverage() {
1884        let offsets: Vec<f64> = (-12..=12).map(|i| i as f64 * 5.0).collect();
1885        let clean = arc_from_tle(ISS_L1, ISS_L2, &offsets);
1886        let mut corrupted = clean.clone();
1887        let outlier = corrupted.len() / 2;
1888        corrupted[outlier].position_teme_km[0] += 8.0;
1889        corrupted[outlier].position_teme_km[1] -= 5.0;
1890
1891        let base_config = FitConfig {
1892            epoch: FitEpoch::Jd(clean[outlier].epoch),
1893            metadata: metadata_from_tle(ISS_L1, ISS_L2),
1894            max_nfev: Some(100),
1895            ..FitConfig::default()
1896        };
1897        let unweighted = fit_tle(&corrupted, &base_config).expect("unweighted fit");
1898        let unweighted_sat =
1899            Satellite::from_elements(&unweighted.elements).expect("unweighted satellite");
1900        let unweighted_clean_rms = clean_position_rms(&clean, &unweighted_sat);
1901
1902        let mut weights = vec![1.0; corrupted.len()];
1903        weights[outlier] = 0.01;
1904        let weighted_config = FitConfig {
1905            weights: Some(weights),
1906            ..base_config
1907        };
1908        let weighted = fit_tle(&corrupted, &weighted_config).expect("weighted fit");
1909        let weighted_sat =
1910            Satellite::from_elements(&weighted.elements).expect("weighted satellite");
1911        let weighted_clean_rms = clean_position_rms(&clean, &weighted_sat);
1912
1913        assert!(
1914            weighted_clean_rms < unweighted_clean_rms,
1915            "weighted clean rms {weighted_clean_rms} unweighted {unweighted_clean_rms}"
1916        );
1917    }
1918
1919    #[test]
1920    fn deterministic_fit_repeats_bit_identically() {
1921        let samples = arc_from_tle(ISS_L1, ISS_L2, &[-30.0, -15.0, 0.0, 15.0, 30.0]);
1922        let config = FitConfig {
1923            epoch: FitEpoch::Sample(2),
1924            metadata: metadata_from_tle(ISS_L1, ISS_L2),
1925            max_nfev: Some(80),
1926            ..FitConfig::default()
1927        };
1928
1929        let a = fit_tle(&samples, &config).expect("first fit");
1930        let b = fit_tle(&samples, &config).expect("second fit");
1931        assert_fit_bit_identical(&a, &b);
1932    }
1933
1934    #[test]
1935    fn iss_ninety_minute_arc_marks_bstar_unobservable() {
1936        let offsets: Vec<f64> = (-9..=9).map(|i| i as f64 * 5.0).collect();
1937        let samples = arc_from_tle(ISS_L1, ISS_L2, &offsets);
1938        let truth = tle::parse(ISS_L1, ISS_L2)
1939            .unwrap()
1940            .elements
1941            .to_element_set()
1942            .unwrap();
1943        let config = FitConfig {
1944            epoch: FitEpoch::Jd(truth.epoch),
1945            metadata: metadata_from_tle(ISS_L1, ISS_L2),
1946            max_nfev: Some(80),
1947            ..FitConfig::default()
1948        };
1949
1950        let fit = fit_tle(&samples, &config).expect("ISS short arc fit");
1951        assert!(!fit.stats.bstar_observable);
1952    }
1953
1954    #[test]
1955    fn validation_reports_typed_errors() {
1956        let mut samples = arc_from_tle(ISS_L1, ISS_L2, &[-10.0, 0.0, 10.0]);
1957        samples[1].epoch = samples[0].epoch;
1958        assert!(matches!(
1959            fit_tle(&samples, &FitConfig::default()),
1960            Err(TleFitError::EpochsNotIncreasing { index: 1 })
1961        ));
1962
1963        let mut samples = arc_from_tle(ISS_L1, ISS_L2, &[-10.0, 0.0, 10.0]);
1964        samples[1].velocity_teme_km_s = None;
1965        assert!(matches!(
1966            fit_tle(&samples, &FitConfig::default()),
1967            Err(TleFitError::MixedVelocityPresence)
1968        ));
1969
1970        let samples = arc_from_tle(ISS_L1, ISS_L2, &[-10.0, 0.0, 10.0]);
1971        let mut config = FitConfig::default();
1972        config.metadata.classification = "X".to_string();
1973        assert!(matches!(
1974            fit_tle(&samples, &config),
1975            Err(TleFitError::InvalidInput {
1976                field: "metadata.classification",
1977                ..
1978            })
1979        ));
1980    }
1981
1982    #[test]
1983    fn did_not_converge_carries_best_effort_fit() {
1984        let offsets: Vec<f64> = (-6..=6).map(|i| i as f64 * 10.0).collect();
1985        let samples = arc_from_tle(ISS_L1, ISS_L2, &offsets);
1986        let truth = tle::parse(ISS_L1, ISS_L2)
1987            .unwrap()
1988            .elements
1989            .to_element_set()
1990            .unwrap();
1991        let config = FitConfig {
1992            epoch: FitEpoch::Jd(truth.epoch),
1993            metadata: metadata_from_tle(ISS_L1, ISS_L2),
1994            max_nfev: Some(2),
1995            ..FitConfig::default()
1996        };
1997
1998        let err = fit_tle(&samples, &config).expect_err("budget stops");
1999        match err {
2000            TleFitError::DidNotConverge { result } => {
2001                assert_eq!(result.stats.status, 0);
2002                assert!(!result.line1.is_empty());
2003                assert!(!result.line2.is_empty());
2004            }
2005            other => panic!("unexpected error {other:?}"),
2006        }
2007    }
2008}