1use 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;
47const 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#[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#[derive(Debug, Clone, Copy, PartialEq, Default, serde::Serialize, serde::Deserialize)]
71pub enum FitEpoch {
72 #[default]
74 Midpoint,
75 First,
76 Last,
77 Sample(usize),
79 Jd(JulianDate),
81}
82
83#[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#[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#[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#[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#[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 #[error("fitted TLE failed encoding: {0}")]
225 TleEncode(tle::TleError),
226}
227
228pub 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 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 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 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 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}