1use crate::astro::constants::time::{SECONDS_PER_DAY, SECONDS_PER_HOUR, SECONDS_PER_MINUTE};
98use crate::astro::constants::{J2_EARTH, MU_EARTH, RE_EARTH};
99use crate::astro::frames::transforms::{
100 gcrs_to_itrs_compute, gcrs_to_itrs_matrix, itrs_to_gcrs_compute, mat3_vec3_mul_unchecked,
101 teme_to_gcrs_compute, TemeStateKm,
102};
103use crate::astro::math::least_squares::{
104 self, solve_trf, LeastSquaresProblem, SolveOptions, Status,
105};
106use crate::astro::math::vec3::{cross3_ref as cross, norm3_ref as norm};
107use crate::astro::sgp4::{JulianDate, Satellite};
108use crate::astro::time::civil::{civil_from_julian_day_number, split_julian_date};
109use crate::astro::time::model::{Instant, JulianDateSplit, TimeScale};
110use crate::astro::time::scales::{julian_day_number, TimeScales};
111use nalgebra::DVector;
112
113use crate::constants::{M_PER_KM, OMEGA_E_DOT_RAD_S};
114use crate::sp3::Sp3;
115use crate::tolerances::{
116 ECCENTRICITY_ZERO_EPS, REDUCED_ORBIT_KEPLER_STEP_EPS_RAD, REDUCED_ORBIT_SOLVER_TOL,
117 VECTOR_NORM_ZERO_EPS,
118};
119use crate::validate;
120use crate::GnssSatelliteId;
121
122mod time;
123use time::dt_seconds;
124pub use time::CalendarEpoch;
125
126pub const MIN_SAMPLES: usize = 4;
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
134pub enum Model {
135 #[default]
138 CircularSecular,
139 EccentricSecular,
142}
143
144#[derive(Debug, Clone, Copy)]
147pub struct EcefSample {
148 pub epoch: CalendarEpoch,
150 pub x_m: f64,
152 pub y_m: f64,
154 pub z_m: f64,
156}
157
158impl EcefSample {
159 pub const fn new(epoch: CalendarEpoch, x_m: f64, y_m: f64, z_m: f64) -> Self {
161 Self {
162 epoch,
163 x_m,
164 y_m,
165 z_m,
166 }
167 }
168}
169
170#[derive(Debug, Clone, Copy, PartialEq)]
176pub struct Elements {
177 pub model: Model,
179 pub epoch: CalendarEpoch,
181 pub a_m: f64,
183 pub e: f64,
186 pub i_rad: f64,
188 pub raan_rad: f64,
190 pub raan_rate_rad_s: f64,
192 pub raan_rate_j2_rad_s: f64,
194 pub arg_lat_rad: f64,
198 pub mean_motion_rad_s: f64,
200 pub h: f64,
202 pub k: f64,
204 pub arg_perigee_rad: f64,
207}
208
209#[derive(Debug, Clone, Copy, PartialEq)]
211pub struct FitStats {
212 pub rms_m: f64,
214 pub max_m: f64,
216 pub n_samples: usize,
218}
219
220#[derive(Debug, Clone, Copy, PartialEq)]
222pub struct ReducedOrbit {
223 pub elements: Elements,
225 pub stats: FitStats,
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231pub enum Frame {
232 Gcrs,
234 Ecef,
236}
237
238#[derive(Debug, Clone, Copy, PartialEq)]
240pub struct DriftEntry {
241 pub epoch: CalendarEpoch,
243 pub error_m: f64,
245}
246
247#[derive(Debug, Clone, PartialEq)]
249pub struct DriftReport {
250 pub per_epoch: Vec<DriftEntry>,
252 pub max_m: f64,
254 pub rms_m: f64,
256 pub threshold_horizon: Option<CalendarEpoch>,
259 pub threshold_index: Option<usize>,
265}
266
267#[derive(Debug, Clone, Copy)]
269pub enum ReducedOrbitSource<'a> {
270 Sp3 {
272 product: &'a Sp3,
273 satellite: GnssSatelliteId,
274 },
275 Sgp4 { satellite: &'a Satellite },
277}
278
279#[derive(Debug, Clone, Copy, PartialEq)]
281pub struct ReducedOrbitSourceSampling {
282 pub t0: CalendarEpoch,
283 pub t1: CalendarEpoch,
284 pub cadence_s: f64,
285}
286
287impl ReducedOrbitSourceSampling {
288 pub const fn new(t0: CalendarEpoch, t1: CalendarEpoch, cadence_s: f64) -> Self {
289 Self { t0, t1, cadence_s }
290 }
291}
292
293#[derive(Debug, Clone, Copy, PartialEq)]
295pub struct ReducedOrbitSourceFitOptions {
296 pub sampling: ReducedOrbitSourceSampling,
297 pub model: Model,
298}
299
300#[derive(Debug, Clone, Copy, PartialEq)]
302pub struct ReducedOrbitSourceDriftOptions {
303 pub sampling: ReducedOrbitSourceSampling,
304 pub threshold_m: f64,
305}
306
307#[derive(Debug, Clone, Copy, PartialEq)]
309pub struct PiecewiseOrbitSourceFitOptions {
310 pub sampling: ReducedOrbitSourceSampling,
311 pub model: Model,
312 pub segment_s: f64,
313}
314
315#[derive(Debug, Clone, PartialEq)]
317pub struct ReducedOrbitSourceFit {
318 pub orbit: ReducedOrbit,
319 pub requested_samples: usize,
320}
321
322#[derive(Debug, Clone, PartialEq)]
324pub struct ReducedOrbitSourceDrift {
325 pub report: DriftReport,
326 pub requested_samples: usize,
327}
328
329#[derive(Debug, Clone, PartialEq)]
331pub struct PiecewiseOrbitSourceFit {
332 pub orbit: PiecewiseOrbit,
333 pub requested_samples: usize,
334}
335
336#[derive(Debug, Clone)]
338pub enum ReducedOrbitSourceError {
339 InvalidWindow,
340 InvalidCadence,
341 InvalidSegment,
342 TooFewSamples { got: usize, required: usize },
343 Reduced(ReducedOrbitError),
344 Piecewise(PiecewiseOrbitError),
345}
346
347impl core::fmt::Display for ReducedOrbitSourceError {
348 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
349 match self {
350 Self::InvalidWindow => write!(f, "invalid reduced-orbit source window"),
351 Self::InvalidCadence => write!(f, "invalid reduced-orbit source cadence"),
352 Self::InvalidSegment => write!(f, "invalid reduced-orbit source segment"),
353 Self::TooFewSamples { got, required } => {
354 write!(f, "only {got} source samples; need at least {required}")
355 }
356 Self::Reduced(error) => write!(f, "{error}"),
357 Self::Piecewise(error) => write!(f, "{error:?}"),
358 }
359 }
360}
361
362impl std::error::Error for ReducedOrbitSourceError {}
363
364#[derive(Debug, Clone, PartialEq)]
366pub struct PiecewiseSegment {
367 pub t0: CalendarEpoch,
369 pub t1: CalendarEpoch,
371 pub orbit: ReducedOrbit,
373}
374
375#[derive(Debug, Clone, PartialEq)]
377pub struct PiecewiseOrbit {
378 pub model: Model,
380 pub t0: CalendarEpoch,
382 pub t1: CalendarEpoch,
384 pub segment_s: i64,
386 pub segments: Vec<PiecewiseSegment>,
388}
389
390#[derive(Debug, Clone)]
392pub enum ReducedOrbitError {
393 TooFewSamples {
395 got: usize,
397 required: usize,
399 },
400 InvalidWindow,
403 SingularPlaneFit,
406 RaanAmbiguous,
409 Singular(least_squares::SolveError),
411 FitDidNotConverge,
414 InvalidInput {
416 field: &'static str,
418 reason: &'static str,
420 },
421}
422
423impl core::fmt::Display for ReducedOrbitError {
424 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
425 match self {
426 ReducedOrbitError::TooFewSamples { got, required } => {
427 write!(f, "only {got} samples; need at least {required}")
428 }
429 ReducedOrbitError::InvalidWindow => {
430 write!(f, "the fit window is empty, inverted, or has no time span")
431 }
432 ReducedOrbitError::SingularPlaneFit => {
433 write!(
434 f,
435 "samples are collinear or coincident; orbital plane undefined"
436 )
437 }
438 ReducedOrbitError::RaanAmbiguous => {
439 write!(f, "near-equatorial orbit; ascending node (raan) undefined")
440 }
441 ReducedOrbitError::Singular(e) => write!(f, "degenerate fit geometry: {e}"),
442 ReducedOrbitError::FitDidNotConverge => {
443 write!(f, "least-squares refinement did not converge")
444 }
445 ReducedOrbitError::InvalidInput { field, reason } => {
446 write!(f, "invalid reduced-orbit input {field}: {reason}")
447 }
448 }
449 }
450}
451
452impl std::error::Error for ReducedOrbitError {}
453
454impl From<least_squares::SolveError> for ReducedOrbitError {
455 fn from(e: least_squares::SolveError) -> Self {
456 ReducedOrbitError::Singular(e)
457 }
458}
459
460#[derive(Debug, Clone)]
462pub enum PiecewiseOrbitError {
463 InvalidSegment,
465 OutOfRange,
467 TooFewSamples {
469 got: usize,
471 required: usize,
473 },
474 Reduced(ReducedOrbitError),
476}
477
478impl From<ReducedOrbitError> for PiecewiseOrbitError {
479 fn from(e: ReducedOrbitError) -> Self {
480 PiecewiseOrbitError::Reduced(e)
481 }
482}
483
484const MIN_INCLINATION_RAD: f64 = 1.0e-3;
487
488const PLANE_NORMAL_SINGULAR_REL_EPS: f64 = 1.0e-9;
493
494const N_PARAMS: usize = 6;
501
502fn unscale_params(x: &[f64], a_scale: f64, rate_scale: f64) -> [f64; N_PARAMS] {
507 [
508 x[0] * a_scale,
509 x[1],
510 x[2],
511 x[3] / rate_scale,
512 x[4],
513 x[5] / rate_scale,
514 ]
515}
516
517const N_PARAMS_ECC: usize = 8;
520
521fn unscale_params_ecc(x: &[f64], a_scale: f64, rate_scale: f64) -> [f64; N_PARAMS_ECC] {
527 [
528 x[0] * a_scale,
529 x[1],
530 x[2],
531 x[3] / rate_scale,
532 x[4],
533 x[5],
534 x[6],
535 x[7] / rate_scale,
536 ]
537}
538
539fn solve_kepler(m: f64, e: f64) -> f64 {
544 let two_pi = 2.0 * std::f64::consts::PI;
545 let m = m.rem_euclid(two_pi);
546 let mut ee = if e < 0.8 { m } else { std::f64::consts::PI };
547 for _ in 0..30 {
548 let f = ee - e * ee.sin() - m;
549 let fp = 1.0 - e * ee.cos();
550 let d = f / fp;
551 ee -= d;
552 if d.abs() < REDUCED_ORBIT_KEPLER_STEP_EPS_RAD {
553 break;
554 }
555 }
556 ee
557}
558
559fn eval_gcrs_km_ecc(p: &[f64], dt: f64) -> [f64; 3] {
562 let a = p[0];
563 let i = p[1];
564 let raan = p[2] + p[3] * dt;
565 let h = p[4];
566 let k = p[5];
567 let lambda = p[6] + p[7] * dt;
568
569 let e = (h * h + k * k).sqrt();
570 if e < ECCENTRICITY_ZERO_EPS {
571 return eval_gcrs_km(&[a, i, p[2], p[3], p[6], p[7]], dt);
573 }
574 let omega = h.atan2(k);
575 let mm = lambda - omega;
576 let big_e = solve_kepler(mm, e);
577 let (se, ce) = big_e.sin_cos();
578 let r = a * (1.0 - e * ce);
579 let nu = (((1.0 - e * e).sqrt()) * se).atan2(ce - e);
580 let u = omega + nu;
581
582 rotate_in_plane_km([r * u.cos(), r * u.sin()], i, raan)
583}
584
585fn rotate_in_plane_km(xy: [f64; 2], i: f64, raan: f64) -> [f64; 3] {
588 let (si, ci) = i.sin_cos();
589 let (sr, cr) = raan.sin_cos();
590 let x1 = xy[0];
591 let y1 = xy[1] * ci;
592 let z1 = xy[1] * si;
593 [cr * x1 - sr * y1, sr * x1 + cr * y1, z1]
594}
595
596fn eval_gcrs_velocity_km_s_ecc(p: &[f64], dt: f64) -> [f64; 3] {
600 let a = p[0];
601 let i = p[1];
602 let raan_rate = p[3];
603 let h = p[4];
604 let k = p[5];
605 let n = p[7];
606 let raan = p[2] + raan_rate * dt;
607 let lambda = p[6] + n * dt;
608
609 let e = (h * h + k * k).sqrt();
610 if e < ECCENTRICITY_ZERO_EPS {
611 return eval_gcrs_velocity_km_s(&[a, i, p[2], p[3], p[6], p[7]], dt);
612 }
613 let omega = h.atan2(k);
614 let mm = lambda - omega;
615 let big_e = solve_kepler(mm, e);
616 let (se, ce) = big_e.sin_cos();
617 let edot = n / (1.0 - e * ce);
619 let beta = (1.0 - e * e).sqrt();
620
621 let x_pf = a * (ce - e);
623 let y_pf = a * beta * se;
624 let xdot_pf = -a * se * edot;
625 let ydot_pf = a * beta * ce * edot;
626
627 let (so, co) = omega.sin_cos();
629 let x1 = co * x_pf - so * y_pf;
630 let y1 = so * x_pf + co * y_pf;
631 let dx1 = co * xdot_pf - so * ydot_pf;
632 let dy1 = so * xdot_pf + co * ydot_pf;
633
634 let (si, ci) = i.sin_cos();
637 let (sr, cr) = raan.sin_cos();
638 let y1i = y1 * ci;
639 let dy1i = dy1 * ci;
640
641 let vx = cr * dx1 - sr * dy1i + raan_rate * (-sr * x1 - cr * y1i);
642 let vy = sr * dx1 + cr * dy1i + raan_rate * (cr * x1 - sr * y1i);
643 let vz = dy1 * si;
644 [vx, vy, vz]
645}
646
647fn eval_gcrs_km(p: &[f64], dt: f64) -> [f64; 3] {
650 let a = p[0];
651 let i = p[1];
652 let raan = p[2] + p[3] * dt;
653 let u = p[4] + p[5] * dt;
654
655 let (su, cu) = u.sin_cos();
657 let xp = a * cu;
658 let yp = a * su;
659
660 let (si, ci) = i.sin_cos();
662 let (sr, cr) = raan.sin_cos();
663
664 let x1 = xp;
666 let y1 = yp * ci;
667 let z1 = yp * si;
668
669 [cr * x1 - sr * y1, sr * x1 + cr * y1, z1]
671}
672
673fn eval_gcrs_velocity_km_s(p: &[f64], dt: f64) -> [f64; 3] {
676 let a = p[0];
677 let i = p[1];
678 let raan_rate = p[3];
679 let n = p[5];
680 let raan = p[2] + raan_rate * dt;
681 let u = p[4] + n * dt;
682
683 let (su, cu) = u.sin_cos();
684 let (si, ci) = i.sin_cos();
685 let (sr, cr) = raan.sin_cos();
686
687 let xp = a * cu;
688 let yp = a * su;
689 let dxp = -a * su * n;
691 let dyp = a * cu * n;
692
693 let x1 = xp;
695 let y1 = yp * ci;
696 let dx1 = dxp;
698 let dy1 = dyp * ci;
699
700 let vx = cr * dx1 - sr * dy1 + raan_rate * (-sr * x1 - cr * y1);
703 let vy = sr * dx1 + cr * dy1 + raan_rate * (cr * x1 - sr * y1);
704 let vz = dyp * si;
705 [vx, vy, vz]
706}
707
708struct GcrsSample {
713 dt: f64,
714 r_km: [f64; 3],
715}
716
717fn seed_params(samples: &[GcrsSample]) -> Result<[f64; N_PARAMS], ReducedOrbitError> {
719 let a_km = samples.iter().map(|s| norm(&s.r_km)).sum::<f64>() / samples.len() as f64;
721 if !a_km.is_finite() || a_km <= 0.0 {
722 return Err(ReducedOrbitError::SingularPlaneFit);
723 }
724
725 let mut h = [0.0_f64; 3];
727 for w in samples.windows(2) {
728 let c = cross(&w[0].r_km, &w[1].r_km);
729 h[0] += c[0];
730 h[1] += c[1];
731 h[2] += c[2];
732 }
733 let hn = norm(&h);
734 if !hn.is_finite() || hn <= a_km * a_km * PLANE_NORMAL_SINGULAR_REL_EPS {
735 return Err(ReducedOrbitError::SingularPlaneFit);
737 }
738 let hhat = [h[0] / hn, h[1] / hn, h[2] / hn];
739
740 let i = hhat[2].clamp(-1.0, 1.0).acos();
742 if i < MIN_INCLINATION_RAD || (std::f64::consts::PI - i) < MIN_INCLINATION_RAD {
743 return Err(ReducedOrbitError::RaanAmbiguous);
744 }
745
746 let node = [-hhat[1], hhat[0], 0.0];
748 let node_n = norm(&node);
749 if node_n <= VECTOR_NORM_ZERO_EPS {
750 return Err(ReducedOrbitError::RaanAmbiguous);
751 }
752 let raan0 = node[1].atan2(node[0]);
753 let nhat = [node[0] / node_n, node[1] / node_n, 0.0];
754
755 let r0 = &samples[0].r_km;
758 let cos_u = (r0[0] * nhat[0] + r0[1] * nhat[1] + r0[2] * nhat[2]) / norm(r0);
759 let p_axis = cross(&hhat, &nhat);
761 let sin_u = (r0[0] * p_axis[0] + r0[1] * p_axis[1] + r0[2] * p_axis[2]) / norm(r0);
762 let arg_lat0 = sin_u.atan2(cos_u);
763
764 let n = (MU_EARTH / (a_km * a_km * a_km)).sqrt();
766
767 let raan_rate = raan_rate_j2(n, i, a_km);
769
770 Ok([a_km, i, raan0, raan_rate, arg_lat0, n])
771}
772
773fn raan_rate_j2(n: f64, i: f64, a_km: f64) -> f64 {
776 let re_over_a = RE_EARTH / a_km;
777 -1.5 * n * J2_EARTH * re_over_a * re_over_a * i.cos()
778}
779
780pub fn fit(samples: &[EcefSample], scale: TimeScale) -> Result<ReducedOrbit, ReducedOrbitError> {
794 fit_with_model(samples, scale, Model::CircularSecular)
795}
796
797pub fn fit_with_model(
804 samples: &[EcefSample],
805 scale: TimeScale,
806 model: Model,
807) -> Result<ReducedOrbit, ReducedOrbitError> {
808 match model {
809 Model::CircularSecular => fit_circular(samples, scale),
810 Model::EccentricSecular => fit_eccentric(samples, scale),
811 }
812}
813
814fn fit_circular(
815 samples: &[EcefSample],
816 scale: TimeScale,
817) -> Result<ReducedOrbit, ReducedOrbitError> {
818 if samples.len() < MIN_SAMPLES {
819 return Err(ReducedOrbitError::TooFewSamples {
820 got: samples.len(),
821 required: MIN_SAMPLES,
822 });
823 }
824 validate_fit_epochs(samples, scale)?;
825
826 let mut ordered: Vec<(TimeScales, &EcefSample)> = samples
829 .iter()
830 .map(|s| (s.epoch.time_scales(scale), s))
831 .collect();
832 ordered.sort_by(|a, b| {
833 (a.0.jd_whole + a.0.tt_fraction).total_cmp(&(b.0.jd_whole + b.0.tt_fraction))
834 });
835
836 let t0_cal = ordered[0].1.epoch;
837 let t0_ts = ordered[0].0;
838
839 let mut gcrs: Vec<GcrsSample> = Vec::with_capacity(samples.len());
841 for (ts, s) in &ordered {
842 let (x, y, z) =
843 itrs_to_gcrs_compute(s.x_m / M_PER_KM, s.y_m / M_PER_KM, s.z_m / M_PER_KM, ts)
844 .expect("valid frame transform");
845 let dt = dt_seconds(&t0_ts, ts);
846 gcrs.push(GcrsSample {
847 dt,
848 r_km: [x, y, z],
849 });
850 }
851
852 let dt_span = gcrs.iter().map(|s| s.dt).fold(f64::NEG_INFINITY, f64::max)
854 - gcrs.iter().map(|s| s.dt).fold(f64::INFINITY, f64::min);
855 if !dt_span.is_finite() || dt_span <= 0.0 {
856 return Err(ReducedOrbitError::InvalidWindow);
857 }
858
859 let seed = seed_params(&gcrs)?;
860
861 let a_scale = seed[0];
869 let rate_scale = dt_span; let seed_scaled = [
871 seed[0] / a_scale,
872 seed[1],
873 seed[2],
874 seed[3] * rate_scale,
875 seed[4],
876 seed[5] * rate_scale,
877 ];
878
879 let m = 3 * gcrs.len();
881 let residual = {
882 let gcrs_ref: Vec<(f64, [f64; 3])> = gcrs.iter().map(|s| (s.dt, s.r_km)).collect();
883 move |x: &DVector<f64>| -> DVector<f64> {
884 let xs = x.as_slice();
885 let p = unscale_params(xs, a_scale, rate_scale);
886 let mut r = Vec::with_capacity(m);
887 for (dt, obs) in &gcrs_ref {
888 let model = eval_gcrs_km(&p, *dt);
889 r.push(model[0] - obs[0]);
890 r.push(model[1] - obs[1]);
891 r.push(model[2] - obs[2]);
892 }
893 DVector::from_vec(r)
894 }
895 };
896
897 let x0 = DVector::from_row_slice(&seed_scaled);
898 let problem = LeastSquaresProblem::new(residual, x0);
899 let opts = SolveOptions {
900 gtol: REDUCED_ORBIT_SOLVER_TOL,
901 ftol: REDUCED_ORBIT_SOLVER_TOL,
902 xtol: REDUCED_ORBIT_SOLVER_TOL,
903 max_nfev: 400,
904 };
905 let report = solve_trf(&problem, &opts)?;
906
907 let converged = matches!(
908 report.status,
909 Status::GradientTolerance | Status::CostTolerance | Status::StepTolerance
910 );
911 if !converged {
912 return Err(ReducedOrbitError::FitDidNotConverge);
913 }
914
915 let p = unscale_params(report.x.as_slice(), a_scale, rate_scale);
916 let res = &report.residual;
918 let n_samp = gcrs.len();
919 let mut sumsq = 0.0;
920 let mut maxsq = 0.0_f64;
921 for k in 0..n_samp {
922 let dx = res[3 * k] * M_PER_KM;
923 let dy = res[3 * k + 1] * M_PER_KM;
924 let dz = res[3 * k + 2] * M_PER_KM;
925 let e2 = dx * dx + dy * dy + dz * dz;
926 sumsq += e2;
927 if e2 > maxsq {
928 maxsq = e2;
929 }
930 }
931 let rms_m = (sumsq / n_samp as f64).sqrt();
932 let max_m = maxsq.sqrt();
933
934 let elements = Elements {
935 model: Model::CircularSecular,
936 epoch: t0_cal,
937 a_m: p[0] * M_PER_KM,
938 e: 0.0,
939 i_rad: p[1],
940 raan_rad: p[2],
941 raan_rate_rad_s: p[3],
942 raan_rate_j2_rad_s: raan_rate_j2(p[5], p[1], p[0]),
943 arg_lat_rad: p[4],
944 mean_motion_rad_s: p[5],
945 h: 0.0,
946 k: 0.0,
947 arg_perigee_rad: 0.0,
948 };
949
950 Ok(ReducedOrbit {
951 elements,
952 stats: FitStats {
953 rms_m,
954 max_m,
955 n_samples: n_samp,
956 },
957 })
958}
959
960fn to_gcrs_samples(
962 samples: &[EcefSample],
963 scale: TimeScale,
964) -> Result<(CalendarEpoch, Vec<GcrsSample>, f64), ReducedOrbitError> {
965 if samples.len() < MIN_SAMPLES {
966 return Err(ReducedOrbitError::TooFewSamples {
967 got: samples.len(),
968 required: MIN_SAMPLES,
969 });
970 }
971 validate_fit_epochs(samples, scale)?;
972
973 let mut ordered: Vec<(TimeScales, &EcefSample)> = samples
974 .iter()
975 .map(|s| (s.epoch.time_scales(scale), s))
976 .collect();
977 ordered.sort_by(|a, b| {
978 (a.0.jd_whole + a.0.tt_fraction).total_cmp(&(b.0.jd_whole + b.0.tt_fraction))
979 });
980
981 let t0_cal = ordered[0].1.epoch;
982 let t0_ts = ordered[0].0;
983
984 let mut gcrs: Vec<GcrsSample> = Vec::with_capacity(samples.len());
985 for (ts, s) in &ordered {
986 let (x, y, z) =
987 itrs_to_gcrs_compute(s.x_m / M_PER_KM, s.y_m / M_PER_KM, s.z_m / M_PER_KM, ts)
988 .expect("valid frame transform");
989 let dt = dt_seconds(&t0_ts, ts);
990 gcrs.push(GcrsSample {
991 dt,
992 r_km: [x, y, z],
993 });
994 }
995
996 let dt_span = gcrs.iter().map(|s| s.dt).fold(f64::NEG_INFINITY, f64::max)
997 - gcrs.iter().map(|s| s.dt).fold(f64::INFINITY, f64::min);
998 if !dt_span.is_finite() || dt_span <= 0.0 {
999 return Err(ReducedOrbitError::InvalidWindow);
1000 }
1001
1002 Ok((t0_cal, gcrs, dt_span))
1003}
1004
1005fn fit_eccentric(
1009 samples: &[EcefSample],
1010 scale: TimeScale,
1011) -> Result<ReducedOrbit, ReducedOrbitError> {
1012 let (t0_cal, gcrs, dt_span) = to_gcrs_samples(samples, scale)?;
1013
1014 let seed_c = seed_params(&gcrs)?;
1016 let a_scale = seed_c[0];
1017 let rate_scale = dt_span;
1018
1019 let seed_scaled = [
1022 1.0, seed_c[1], seed_c[2], seed_c[3] * rate_scale, 0.0, 0.0, seed_c[4], seed_c[5] * rate_scale, ];
1031
1032 let m = 3 * gcrs.len();
1033 let residual = {
1034 let gcrs_ref: Vec<(f64, [f64; 3])> = gcrs.iter().map(|s| (s.dt, s.r_km)).collect();
1035 move |x: &DVector<f64>| -> DVector<f64> {
1036 let xs = x.as_slice();
1037 let p = unscale_params_ecc(xs, a_scale, rate_scale);
1038 let mut r = Vec::with_capacity(m);
1039 for (dt, obs) in &gcrs_ref {
1040 let model = eval_gcrs_km_ecc(&p, *dt);
1041 r.push(model[0] - obs[0]);
1042 r.push(model[1] - obs[1]);
1043 r.push(model[2] - obs[2]);
1044 }
1045 DVector::from_vec(r)
1046 }
1047 };
1048
1049 let x0 = DVector::from_row_slice(&seed_scaled);
1050 let problem = LeastSquaresProblem::new(residual, x0);
1051 let opts = SolveOptions {
1052 gtol: REDUCED_ORBIT_SOLVER_TOL,
1053 ftol: REDUCED_ORBIT_SOLVER_TOL,
1054 xtol: REDUCED_ORBIT_SOLVER_TOL,
1055 max_nfev: 400,
1056 };
1057 let report = solve_trf(&problem, &opts)?;
1058
1059 let converged = matches!(
1060 report.status,
1061 Status::GradientTolerance | Status::CostTolerance | Status::StepTolerance
1062 );
1063 if !converged {
1064 return Err(ReducedOrbitError::FitDidNotConverge);
1065 }
1066
1067 let p = unscale_params_ecc(report.x.as_slice(), a_scale, rate_scale);
1068 let res = &report.residual;
1069 let n_samp = gcrs.len();
1070 let mut sumsq = 0.0;
1071 let mut maxsq = 0.0_f64;
1072 for k in 0..n_samp {
1073 let dx = res[3 * k] * M_PER_KM;
1074 let dy = res[3 * k + 1] * M_PER_KM;
1075 let dz = res[3 * k + 2] * M_PER_KM;
1076 let e2 = dx * dx + dy * dy + dz * dz;
1077 sumsq += e2;
1078 if e2 > maxsq {
1079 maxsq = e2;
1080 }
1081 }
1082 let rms_m = (sumsq / n_samp as f64).sqrt();
1083 let max_m = maxsq.sqrt();
1084
1085 let h = p[4];
1086 let k = p[5];
1087 let e = (h * h + k * k).sqrt();
1088 let arg_perigee_rad = if e < ECCENTRICITY_ZERO_EPS {
1089 0.0
1090 } else {
1091 h.atan2(k)
1092 };
1093
1094 let elements = Elements {
1095 model: Model::EccentricSecular,
1096 epoch: t0_cal,
1097 a_m: p[0] * M_PER_KM,
1098 e,
1099 i_rad: p[1],
1100 raan_rad: p[2],
1101 raan_rate_rad_s: p[3],
1102 raan_rate_j2_rad_s: raan_rate_j2(p[7], p[1], p[0]),
1103 arg_lat_rad: p[6],
1104 mean_motion_rad_s: p[7],
1105 h,
1106 k,
1107 arg_perigee_rad,
1108 };
1109
1110 Ok(ReducedOrbit {
1111 elements,
1112 stats: FitStats {
1113 rms_m,
1114 max_m,
1115 n_samples: n_samp,
1116 },
1117 })
1118}
1119
1120fn params_from_elements(e: &Elements) -> [f64; N_PARAMS] {
1125 [
1126 e.a_m / M_PER_KM,
1127 e.i_rad,
1128 e.raan_rad,
1129 e.raan_rate_rad_s,
1130 e.arg_lat_rad,
1131 e.mean_motion_rad_s,
1132 ]
1133}
1134
1135fn params_from_elements_ecc(e: &Elements) -> [f64; N_PARAMS_ECC] {
1136 [
1137 e.a_m / M_PER_KM,
1138 e.i_rad,
1139 e.raan_rad,
1140 e.raan_rate_rad_s,
1141 e.h,
1142 e.k,
1143 e.arg_lat_rad,
1144 e.mean_motion_rad_s,
1145 ]
1146}
1147
1148fn eval_position_km(elements: &Elements, dt: f64) -> [f64; 3] {
1150 match elements.model {
1151 Model::CircularSecular => eval_gcrs_km(¶ms_from_elements(elements), dt),
1152 Model::EccentricSecular => eval_gcrs_km_ecc(¶ms_from_elements_ecc(elements), dt),
1153 }
1154}
1155
1156fn eval_velocity_km_s(elements: &Elements, dt: f64) -> [f64; 3] {
1158 match elements.model {
1159 Model::CircularSecular => eval_gcrs_velocity_km_s(¶ms_from_elements(elements), dt),
1160 Model::EccentricSecular => {
1161 eval_gcrs_velocity_km_s_ecc(¶ms_from_elements_ecc(elements), dt)
1162 }
1163 }
1164}
1165
1166pub fn position(
1169 elements: &Elements,
1170 epoch: CalendarEpoch,
1171 scale: TimeScale,
1172 frame: Frame,
1173) -> Result<[f64; 3], ReducedOrbitError> {
1174 validate_elements_for_evaluation(elements, scale)?;
1175 validate_calendar_epoch(epoch, scale, "epoch")?;
1176 let t0_ts = elements.epoch.time_scales(scale);
1177 let ts = epoch.time_scales(scale);
1178 let dt = dt_seconds(&t0_ts, &ts);
1179 validate_finite(dt, "dt_s")?;
1180 let r_gcrs_km = eval_position_km(elements, dt);
1181 let r = match frame {
1182 Frame::Gcrs => [
1183 r_gcrs_km[0] * M_PER_KM,
1184 r_gcrs_km[1] * M_PER_KM,
1185 r_gcrs_km[2] * M_PER_KM,
1186 ],
1187 Frame::Ecef => {
1188 let mat = gcrs_to_itrs_matrix(&ts)
1189 .map_err(|_| invalid_input("epoch", "invalid frame transform"))?;
1190 let r = mat3_vec3_mul_unchecked(&mat, &r_gcrs_km);
1191 [r[0] * M_PER_KM, r[1] * M_PER_KM, r[2] * M_PER_KM]
1192 }
1193 };
1194 validate_vec3(r, "position_m")?;
1195 Ok(r)
1196}
1197
1198pub fn position_velocity(
1202 elements: &Elements,
1203 epoch: CalendarEpoch,
1204 scale: TimeScale,
1205 frame: Frame,
1206) -> Result<([f64; 3], [f64; 3]), ReducedOrbitError> {
1207 validate_elements_for_evaluation(elements, scale)?;
1208 validate_calendar_epoch(epoch, scale, "epoch")?;
1209 let t0_ts = elements.epoch.time_scales(scale);
1210 let ts = epoch.time_scales(scale);
1211 let dt = dt_seconds(&t0_ts, &ts);
1212 validate_finite(dt, "dt_s")?;
1213 let r_gcrs_km = eval_position_km(elements, dt);
1214 let v_gcrs_km_s = eval_velocity_km_s(elements, dt);
1215
1216 let (r, v) = match frame {
1217 Frame::Gcrs => {
1218 let r = [
1219 r_gcrs_km[0] * M_PER_KM,
1220 r_gcrs_km[1] * M_PER_KM,
1221 r_gcrs_km[2] * M_PER_KM,
1222 ];
1223 let v = [
1224 v_gcrs_km_s[0] * M_PER_KM,
1225 v_gcrs_km_s[1] * M_PER_KM,
1226 v_gcrs_km_s[2] * M_PER_KM,
1227 ];
1228 (r, v)
1229 }
1230 Frame::Ecef => {
1231 let mat = gcrs_to_itrs_matrix(&ts)
1232 .map_err(|_| invalid_input("epoch", "invalid frame transform"))?;
1233 let r_itrs_km = mat3_vec3_mul_unchecked(&mat, &r_gcrs_km);
1234 let v_rot_km_s = mat3_vec3_mul_unchecked(&mat, &v_gcrs_km_s);
1235 let vx = v_rot_km_s[0] + OMEGA_E_DOT_RAD_S * r_itrs_km[1];
1237 let vy = v_rot_km_s[1] - OMEGA_E_DOT_RAD_S * r_itrs_km[0];
1238 let vz = v_rot_km_s[2];
1239 let r = [
1240 r_itrs_km[0] * M_PER_KM,
1241 r_itrs_km[1] * M_PER_KM,
1242 r_itrs_km[2] * M_PER_KM,
1243 ];
1244 let v = [vx * M_PER_KM, vy * M_PER_KM, vz * M_PER_KM];
1245 (r, v)
1246 }
1247 };
1248 validate_vec3(r, "position_m")?;
1249 validate_vec3(v, "velocity_m_s")?;
1250 Ok((r, v))
1251}
1252
1253fn validate_elements_for_evaluation(
1254 elements: &Elements,
1255 scale: TimeScale,
1256) -> Result<(), ReducedOrbitError> {
1257 validate_calendar_epoch(elements.epoch, scale, "elements.epoch")?;
1258 validate_positive(elements.a_m, "elements.a_m")?;
1259 validate_finite(elements.e, "elements.e")?;
1260 if !(0.0..1.0).contains(&elements.e) {
1261 return Err(invalid_input("elements.e", "must be in [0, 1)"));
1262 }
1263 validate_finite(elements.i_rad, "elements.i_rad")?;
1264 if !(0.0..=std::f64::consts::PI).contains(&elements.i_rad) {
1265 return Err(invalid_input("elements.i_rad", "must be in [0, pi]"));
1266 }
1267 validate_finite(elements.raan_rad, "elements.raan_rad")?;
1268 validate_finite(elements.raan_rate_rad_s, "elements.raan_rate_rad_s")?;
1269 validate_finite(elements.raan_rate_j2_rad_s, "elements.raan_rate_j2_rad_s")?;
1270 validate_finite(elements.arg_lat_rad, "elements.arg_lat_rad")?;
1271 validate_positive(elements.mean_motion_rad_s, "elements.mean_motion_rad_s")?;
1272 validate_finite(elements.h, "elements.h")?;
1273 validate_finite(elements.k, "elements.k")?;
1274 validate_finite(elements.arg_perigee_rad, "elements.arg_perigee_rad")?;
1275 if elements.model == Model::EccentricSecular {
1276 let derived_e = (elements.h * elements.h + elements.k * elements.k).sqrt();
1277 validate_finite(derived_e, "elements.h_k")?;
1278 if derived_e >= 1.0 {
1279 return Err(invalid_input("elements.h_k", "eccentricity must be < 1"));
1280 }
1281 }
1282 Ok(())
1283}
1284
1285fn validate_calendar_epoch(
1286 epoch: CalendarEpoch,
1287 scale: TimeScale,
1288 field: &'static str,
1289) -> Result<(), ReducedOrbitError> {
1290 let second_policy = match scale {
1291 TimeScale::Utc => validate::CivilSecondPolicy::UtcLike,
1292 TimeScale::Glonasst
1300 | TimeScale::Tai
1301 | TimeScale::Tt
1302 | TimeScale::Tcg
1303 | TimeScale::Tdb
1304 | TimeScale::Tcb
1305 | TimeScale::Gpst
1306 | TimeScale::Gst
1307 | TimeScale::Bdt
1308 | TimeScale::Qzsst => validate::CivilSecondPolicy::Continuous,
1309 };
1310 validate::civil_datetime_with_second_policy(
1311 i64::from(epoch.year),
1312 i64::from(epoch.month),
1313 i64::from(epoch.day),
1314 i64::from(epoch.hour),
1315 i64::from(epoch.minute),
1316 epoch.second,
1317 second_policy,
1318 )
1319 .map(|_| ())
1320 .map_err(|_| invalid_input(field, "invalid calendar epoch"))
1321}
1322
1323fn validate_fit_epochs(samples: &[EcefSample], scale: TimeScale) -> Result<(), ReducedOrbitError> {
1324 for sample in samples {
1325 validate_calendar_epoch(sample.epoch, scale, "epoch")?;
1326 validate_finite(sample.x_m, "sample.x_m")?;
1327 validate_finite(sample.y_m, "sample.y_m")?;
1328 validate_finite(sample.z_m, "sample.z_m")?;
1329 }
1330 Ok(())
1331}
1332
1333fn validate_truth_sample(sample: &EcefSample, scale: TimeScale) -> Result<(), ReducedOrbitError> {
1334 validate_calendar_epoch(sample.epoch, scale, "truth.epoch")?;
1335 validate_finite(sample.x_m, "truth.x_m")?;
1336 validate_finite(sample.y_m, "truth.y_m")?;
1337 validate_finite(sample.z_m, "truth.z_m")
1338}
1339
1340fn validate_vec3(value: [f64; 3], field: &'static str) -> Result<(), ReducedOrbitError> {
1341 for component in value {
1342 validate_finite(component, field)?;
1343 }
1344 Ok(())
1345}
1346
1347fn validate_finite(value: f64, field: &'static str) -> Result<(), ReducedOrbitError> {
1348 if value.is_finite() {
1349 Ok(())
1350 } else {
1351 Err(invalid_input(field, "must be finite"))
1352 }
1353}
1354
1355fn validate_positive(value: f64, field: &'static str) -> Result<(), ReducedOrbitError> {
1356 validate_finite(value, field)?;
1357 if value > 0.0 {
1358 Ok(())
1359 } else {
1360 Err(invalid_input(field, "must be positive"))
1361 }
1362}
1363
1364fn invalid_input(field: &'static str, reason: &'static str) -> ReducedOrbitError {
1365 ReducedOrbitError::InvalidInput { field, reason }
1366}
1367
1368pub fn drift(
1379 elements: &Elements,
1380 truth: &[EcefSample],
1381 scale: TimeScale,
1382 threshold_m: f64,
1383) -> Result<DriftReport, ReducedOrbitError> {
1384 validate_elements_for_evaluation(elements, scale)?;
1385 validate_finite(threshold_m, "threshold_m")?;
1386 let mut per_epoch = Vec::with_capacity(truth.len());
1387 let mut sumsq = 0.0;
1388 let mut max_m = 0.0_f64;
1389 let mut threshold_horizon = None;
1390 let mut threshold_index = None;
1391
1392 for s in truth {
1393 validate_truth_sample(s, scale)?;
1394 let model = position(elements, s.epoch, scale, Frame::Ecef)?;
1395 let dx = model[0] - s.x_m;
1396 let dy = model[1] - s.y_m;
1397 let dz = model[2] - s.z_m;
1398 let err = (dx * dx + dy * dy + dz * dz).sqrt();
1399 validate_finite(err, "drift.error_m")?;
1400 sumsq += err * err;
1401 validate_finite(sumsq, "drift.sumsq")?;
1402 if err > max_m {
1403 max_m = err;
1404 }
1405 if threshold_horizon.is_none() && err > threshold_m {
1406 threshold_horizon = Some(s.epoch);
1407 threshold_index = Some(per_epoch.len());
1408 }
1409 per_epoch.push(DriftEntry {
1410 epoch: s.epoch,
1411 error_m: err,
1412 });
1413 }
1414
1415 let rms_m = if per_epoch.is_empty() {
1416 0.0
1417 } else {
1418 (sumsq / per_epoch.len() as f64).sqrt()
1419 };
1420 validate_finite(max_m, "drift.max_m")?;
1421 validate_finite(rms_m, "drift.rms_m")?;
1422
1423 Ok(DriftReport {
1424 per_epoch,
1425 max_m,
1426 rms_m,
1427 threshold_horizon,
1428 threshold_index,
1429 })
1430}
1431
1432pub fn fit_reduced_orbit_source(
1434 source: ReducedOrbitSource<'_>,
1435 options: ReducedOrbitSourceFitOptions,
1436) -> Result<ReducedOrbitSourceFit, ReducedOrbitSourceError> {
1437 let sampled = sample_reduced_orbit_source(source, options.sampling)?;
1438 let orbit = fit_with_model(&sampled.samples, sampled.scale, options.model)
1439 .map_err(ReducedOrbitSourceError::Reduced)?;
1440 Ok(ReducedOrbitSourceFit {
1441 orbit,
1442 requested_samples: sampled.requested,
1443 })
1444}
1445
1446pub fn drift_reduced_orbit_source(
1448 elements: &Elements,
1449 source: ReducedOrbitSource<'_>,
1450 options: ReducedOrbitSourceDriftOptions,
1451) -> Result<ReducedOrbitSourceDrift, ReducedOrbitSourceError> {
1452 let sampled = sample_reduced_orbit_source(source, options.sampling)?;
1453 if sampled.samples.is_empty() {
1454 return Err(ReducedOrbitSourceError::TooFewSamples {
1455 got: 0,
1456 required: 1,
1457 });
1458 }
1459 let report = drift(
1460 elements,
1461 &sampled.samples,
1462 sampled.scale,
1463 options.threshold_m,
1464 )
1465 .map_err(ReducedOrbitSourceError::Reduced)?;
1466 Ok(ReducedOrbitSourceDrift {
1467 report,
1468 requested_samples: sampled.requested,
1469 })
1470}
1471
1472pub fn fit_piecewise_reduced_orbit_source(
1474 source: ReducedOrbitSource<'_>,
1475 options: PiecewiseOrbitSourceFitOptions,
1476) -> Result<PiecewiseOrbitSourceFit, ReducedOrbitSourceError> {
1477 let sampled = sample_reduced_orbit_source(source, options.sampling)?;
1478 let segment_s = rounded_segment_s(options.segment_s)?;
1479 let orbit = fit_piecewise(
1480 &sampled.samples,
1481 sampled.scale,
1482 options.model,
1483 options.sampling.t0,
1484 options.sampling.t1,
1485 segment_s,
1486 )
1487 .map_err(ReducedOrbitSourceError::Piecewise)?;
1488 Ok(PiecewiseOrbitSourceFit {
1489 orbit,
1490 requested_samples: sampled.requested,
1491 })
1492}
1493
1494pub fn drift_piecewise_reduced_orbit_source(
1496 piecewise: &PiecewiseOrbit,
1497 source: ReducedOrbitSource<'_>,
1498 options: ReducedOrbitSourceDriftOptions,
1499) -> Result<ReducedOrbitSourceDrift, ReducedOrbitSourceError> {
1500 let sampled = sample_reduced_orbit_source(source, options.sampling)?;
1501 if sampled.samples.is_empty() {
1502 return Err(ReducedOrbitSourceError::TooFewSamples {
1503 got: 0,
1504 required: 1,
1505 });
1506 }
1507 let report = piecewise_drift(
1508 piecewise,
1509 &sampled.samples,
1510 sampled.scale,
1511 options.threshold_m,
1512 )
1513 .map_err(ReducedOrbitSourceError::Piecewise)?;
1514 Ok(ReducedOrbitSourceDrift {
1515 report,
1516 requested_samples: sampled.requested,
1517 })
1518}
1519
1520#[derive(Debug)]
1521struct SourceSamples {
1522 samples: Vec<EcefSample>,
1523 requested: usize,
1524 scale: TimeScale,
1525}
1526
1527fn sample_reduced_orbit_source(
1528 source: ReducedOrbitSource<'_>,
1529 sampling: ReducedOrbitSourceSampling,
1530) -> Result<SourceSamples, ReducedOrbitSourceError> {
1531 let scale = match source {
1532 ReducedOrbitSource::Sp3 { product, .. } => product.header.time_scale,
1533 ReducedOrbitSource::Sgp4 { .. } => TimeScale::Utc,
1534 };
1535 let steps = source_steps(sampling, scale)?;
1536 let samples = match source {
1537 ReducedOrbitSource::Sp3 { product, satellite } => steps
1538 .iter()
1539 .filter_map(|epoch| sample_sp3_epoch(product, satellite, *epoch))
1540 .collect(),
1541 ReducedOrbitSource::Sgp4 { satellite } => steps
1542 .iter()
1543 .filter_map(|epoch| sample_sgp4_epoch(satellite, *epoch))
1544 .collect(),
1545 };
1546 Ok(SourceSamples {
1547 samples,
1548 requested: steps.len(),
1549 scale,
1550 })
1551}
1552
1553fn source_steps(
1554 sampling: ReducedOrbitSourceSampling,
1555 scale: TimeScale,
1556) -> Result<Vec<CalendarEpoch>, ReducedOrbitSourceError> {
1557 validate_calendar_epoch(sampling.t0, scale, "window.start")
1558 .map_err(ReducedOrbitSourceError::Reduced)?;
1559 validate_calendar_epoch(sampling.t1, scale, "window.end")
1560 .map_err(ReducedOrbitSourceError::Reduced)?;
1561 if !sampling.cadence_s.is_finite() || sampling.cadence_s <= 0.0 {
1562 return Err(ReducedOrbitSourceError::InvalidCadence);
1563 }
1564 let span_s = calendar_seconds(sampling.t1) - calendar_seconds(sampling.t0);
1565 if !span_s.is_finite() || span_s <= 0.0 {
1566 return Err(ReducedOrbitSourceError::InvalidWindow);
1567 }
1568 let count = (span_s / sampling.cadence_s).trunc();
1569 if !count.is_finite() || count < 0.0 {
1570 return Err(ReducedOrbitSourceError::InvalidWindow);
1571 }
1572 let start_s = calendar_seconds(sampling.t0);
1573 Ok((0..=count as usize)
1574 .map(|k| calendar_from_seconds(start_s + (k as f64 * sampling.cadence_s).round()))
1575 .collect())
1576}
1577
1578fn sample_sp3_epoch(
1579 product: &Sp3,
1580 satellite: GnssSatelliteId,
1581 epoch: CalendarEpoch,
1582) -> Option<EcefSample> {
1583 let instant = instant_from_calendar(epoch, product.header.time_scale).ok()?;
1584 let state = product.position(satellite, instant).ok()?;
1585 Some(EcefSample::new(
1586 epoch,
1587 state.position.x_m,
1588 state.position.y_m,
1589 state.position.z_m,
1590 ))
1591}
1592
1593fn sample_sgp4_epoch(satellite: &Satellite, epoch: CalendarEpoch) -> Option<EcefSample> {
1594 let prediction = satellite
1595 .propagate_jd(julian_date_from_calendar(epoch))
1596 .ok()?;
1597 let ts = epoch.time_scales(TimeScale::Utc);
1598 let (gcrs, _) = teme_to_gcrs_compute(
1599 &TemeStateKm {
1600 position_km: prediction.position,
1601 velocity_km_s: prediction.velocity,
1602 },
1603 &ts,
1604 false,
1605 )
1606 .ok()?;
1607 let (x_km, y_km, z_km) = gcrs_to_itrs_compute(gcrs.0, gcrs.1, gcrs.2, &ts, false).ok()?;
1608 Some(EcefSample::new(
1609 epoch,
1610 x_km * M_PER_KM,
1611 y_km * M_PER_KM,
1612 z_km * M_PER_KM,
1613 ))
1614}
1615
1616fn instant_from_calendar(
1617 epoch: CalendarEpoch,
1618 scale: TimeScale,
1619) -> Result<Instant, ReducedOrbitSourceError> {
1620 let (jd_whole, fraction) = split_julian_date(
1621 epoch.year,
1622 epoch.month,
1623 epoch.day,
1624 epoch.hour,
1625 epoch.minute,
1626 epoch.second,
1627 );
1628 let split = JulianDateSplit::new(jd_whole, fraction)
1629 .map_err(|_| ReducedOrbitSourceError::InvalidWindow)?;
1630 Ok(Instant::from_julian_date(scale, split))
1631}
1632
1633fn julian_date_from_calendar(epoch: CalendarEpoch) -> JulianDate {
1634 let (jd_whole, fraction) = split_julian_date(
1635 epoch.year,
1636 epoch.month,
1637 epoch.day,
1638 epoch.hour,
1639 epoch.minute,
1640 epoch.second,
1641 );
1642 JulianDate(jd_whole, fraction)
1643}
1644
1645fn rounded_segment_s(segment_s: f64) -> Result<i64, ReducedOrbitSourceError> {
1646 if !segment_s.is_finite() || segment_s <= 0.0 {
1647 return Err(ReducedOrbitSourceError::InvalidSegment);
1648 }
1649 let rounded = segment_s.round();
1650 if rounded < 1.0 || rounded > i64::MAX as f64 {
1651 return Err(ReducedOrbitSourceError::InvalidSegment);
1652 }
1653 Ok(rounded as i64)
1654}
1655
1656fn calendar_seconds(t: CalendarEpoch) -> f64 {
1666 julian_day_number(t.year, t.month, t.day) as f64 * SECONDS_PER_DAY
1667 + (t.hour as f64) * SECONDS_PER_HOUR
1668 + (t.minute as f64) * SECONDS_PER_MINUTE
1669 + t.second
1670}
1671
1672fn calendar_from_seconds(total_s: f64) -> CalendarEpoch {
1673 let mut jdn = (total_s / SECONDS_PER_DAY).floor() as i64;
1674 let mut sod = total_s - jdn as f64 * SECONDS_PER_DAY;
1675 if sod < 0.0 {
1676 jdn -= 1;
1677 sod += SECONDS_PER_DAY;
1678 }
1679 if sod >= SECONDS_PER_DAY {
1680 jdn += 1;
1681 sod -= SECONDS_PER_DAY;
1682 }
1683
1684 let (year, month, day) = civil_from_julian_day_number(jdn);
1685 let hour = (sod / SECONDS_PER_HOUR).floor() as i32;
1686 let rem = sod - hour as f64 * SECONDS_PER_HOUR;
1687 let minute = (rem / SECONDS_PER_MINUTE).floor() as i32;
1688 let second = rem - minute as f64 * SECONDS_PER_MINUTE;
1689
1690 CalendarEpoch::new(year as i32, month as i32, day as i32, hour, minute, second)
1691}
1692
1693fn calendar_add_seconds(t: CalendarEpoch, seconds: i64) -> CalendarEpoch {
1694 calendar_from_seconds(calendar_seconds(t) + seconds as f64)
1695}
1696
1697fn segment_bounds(
1698 t0: CalendarEpoch,
1699 t1: CalendarEpoch,
1700 segment_s: i64,
1701) -> Result<Vec<(CalendarEpoch, CalendarEpoch)>, PiecewiseOrbitError> {
1702 if segment_s < 1 {
1703 return Err(PiecewiseOrbitError::InvalidSegment);
1704 }
1705 if calendar_seconds(t1) <= calendar_seconds(t0) {
1706 return Err(PiecewiseOrbitError::Reduced(
1707 ReducedOrbitError::InvalidWindow,
1708 ));
1709 }
1710
1711 let mut bounds = Vec::new();
1712 let mut seg_t0 = t0;
1713 let end_s = calendar_seconds(t1);
1714 while calendar_seconds(seg_t0) < end_s {
1715 let mut seg_t1 = calendar_add_seconds(seg_t0, segment_s);
1716 if calendar_seconds(seg_t1) > end_s {
1717 seg_t1 = t1;
1718 }
1719 bounds.push((seg_t0, seg_t1));
1720 seg_t0 = seg_t1;
1721 }
1722 Ok(bounds)
1723}
1724
1725fn is_too_few_or_invalid_window(e: &ReducedOrbitError) -> bool {
1726 matches!(
1727 e,
1728 ReducedOrbitError::TooFewSamples { .. } | ReducedOrbitError::InvalidWindow
1729 )
1730}
1731
1732fn sample_in_bounds(sample: CalendarEpoch, t0: CalendarEpoch, t1: CalendarEpoch) -> bool {
1733 let s = calendar_seconds(sample);
1734 s >= calendar_seconds(t0) && s <= calendar_seconds(t1)
1735}
1736
1737pub fn fit_piecewise(
1746 samples: &[EcefSample],
1747 scale: TimeScale,
1748 model: Model,
1749 t0: CalendarEpoch,
1750 t1: CalendarEpoch,
1751 segment_s: i64,
1752) -> Result<PiecewiseOrbit, PiecewiseOrbitError> {
1753 let bounds = segment_bounds(t0, t1, segment_s)?;
1754 let last_index = bounds.len().saturating_sub(1);
1755 let mut segments = Vec::new();
1756
1757 for (index, (seg_t0, seg_t1)) in bounds.into_iter().enumerate() {
1758 let subset: Vec<EcefSample> = samples
1759 .iter()
1760 .copied()
1761 .filter(|s| sample_in_bounds(s.epoch, seg_t0, seg_t1))
1762 .collect();
1763
1764 match fit_with_model(&subset, scale, model) {
1765 Ok(orbit) => segments.push(PiecewiseSegment {
1766 t0: seg_t0,
1767 t1: seg_t1,
1768 orbit,
1769 }),
1770 Err(e) if index == last_index && is_too_few_or_invalid_window(&e) => {}
1771 Err(e) => return Err(PiecewiseOrbitError::Reduced(e)),
1772 }
1773 }
1774
1775 let Some(last) = segments.last() else {
1776 return Err(PiecewiseOrbitError::TooFewSamples {
1777 got: 0,
1778 required: MIN_SAMPLES,
1779 });
1780 };
1781
1782 Ok(PiecewiseOrbit {
1783 model,
1784 t0,
1785 t1: last.t1,
1786 segment_s,
1787 segments,
1788 })
1789}
1790
1791pub fn select_piecewise_segment(
1796 piecewise: &PiecewiseOrbit,
1797 epoch: CalendarEpoch,
1798) -> Result<&PiecewiseSegment, PiecewiseOrbitError> {
1799 validate_piecewise_segments(piecewise)?;
1800 let e = calendar_seconds(epoch);
1801 if e < calendar_seconds(piecewise.t0) || e > calendar_seconds(piecewise.t1) {
1802 return Err(PiecewiseOrbitError::OutOfRange);
1803 }
1804
1805 let Some((last, rest)) = piecewise.segments.split_last() else {
1806 return Err(PiecewiseOrbitError::OutOfRange);
1807 };
1808
1809 for seg in rest {
1810 if e >= calendar_seconds(seg.t0) && e < calendar_seconds(seg.t1) {
1811 return Ok(seg);
1812 }
1813 }
1814
1815 if e >= calendar_seconds(last.t0) && e <= calendar_seconds(last.t1) {
1816 Ok(last)
1817 } else {
1818 Err(PiecewiseOrbitError::OutOfRange)
1819 }
1820}
1821
1822fn validate_piecewise_segments(piecewise: &PiecewiseOrbit) -> Result<(), PiecewiseOrbitError> {
1823 if piecewise.segment_s < 1 {
1824 return Err(PiecewiseOrbitError::InvalidSegment);
1825 }
1826 validate::require_strictly_increasing(
1827 piecewise
1828 .segments
1829 .iter()
1830 .map(|segment| calendar_seconds(segment.t0)),
1831 "piecewise.segments.t0",
1832 )
1833 .map_err(map_piecewise_order_error)?;
1834
1835 for segment in &piecewise.segments {
1836 validate::require_strictly_increasing(
1837 [calendar_seconds(segment.t0), calendar_seconds(segment.t1)],
1838 "piecewise.segment bounds",
1839 )
1840 .map_err(map_piecewise_order_error)?;
1841 }
1842 Ok(())
1843}
1844
1845fn map_piecewise_order_error(error: validate::FieldError) -> PiecewiseOrbitError {
1846 let reason = match error {
1847 validate::FieldError::NonFinite { .. } => "must be finite",
1848 validate::FieldError::OutOfRange { .. } => "must be strictly increasing",
1849 _ => error.reason(),
1850 };
1851 PiecewiseOrbitError::Reduced(invalid_input(error.field(), reason))
1852}
1853
1854pub fn piecewise_position(
1856 piecewise: &PiecewiseOrbit,
1857 epoch: CalendarEpoch,
1858 scale: TimeScale,
1859 frame: Frame,
1860) -> Result<[f64; 3], PiecewiseOrbitError> {
1861 validate_calendar_epoch(epoch, scale, "epoch").map_err(PiecewiseOrbitError::Reduced)?;
1862 let seg = select_piecewise_segment(piecewise, epoch)?;
1863 position(&seg.orbit.elements, epoch, scale, frame).map_err(PiecewiseOrbitError::Reduced)
1864}
1865
1866pub fn piecewise_position_velocity(
1868 piecewise: &PiecewiseOrbit,
1869 epoch: CalendarEpoch,
1870 scale: TimeScale,
1871 frame: Frame,
1872) -> Result<([f64; 3], [f64; 3]), PiecewiseOrbitError> {
1873 validate_calendar_epoch(epoch, scale, "epoch").map_err(PiecewiseOrbitError::Reduced)?;
1874 let seg = select_piecewise_segment(piecewise, epoch)?;
1875 position_velocity(&seg.orbit.elements, epoch, scale, frame)
1876 .map_err(PiecewiseOrbitError::Reduced)
1877}
1878
1879pub fn piecewise_drift(
1884 piecewise: &PiecewiseOrbit,
1885 truth: &[EcefSample],
1886 scale: TimeScale,
1887 threshold_m: f64,
1888) -> Result<DriftReport, PiecewiseOrbitError> {
1889 validate_finite(threshold_m, "threshold_m").map_err(PiecewiseOrbitError::Reduced)?;
1890 if truth.is_empty() {
1891 return Ok(DriftReport {
1892 per_epoch: Vec::new(),
1893 max_m: 0.0,
1894 rms_m: 0.0,
1895 threshold_horizon: None,
1896 threshold_index: None,
1897 });
1898 }
1899
1900 let mut per_epoch = Vec::with_capacity(truth.len());
1901 let mut sumsq = 0.0;
1902 let mut max_m = 0.0_f64;
1903 let mut threshold_horizon = None;
1904 let mut threshold_index = None;
1905
1906 for s in truth {
1907 validate_truth_sample(s, scale).map_err(PiecewiseOrbitError::Reduced)?;
1908 let Ok(model) = piecewise_position(piecewise, s.epoch, scale, Frame::Ecef) else {
1909 continue;
1910 };
1911 let dx = model[0] - s.x_m;
1912 let dy = model[1] - s.y_m;
1913 let dz = model[2] - s.z_m;
1914 let err = (dx * dx + dy * dy + dz * dz).sqrt();
1915 sumsq += err * err;
1916 if err > max_m {
1917 max_m = err;
1918 }
1919 if threshold_horizon.is_none() && err > threshold_m {
1920 threshold_horizon = Some(s.epoch);
1921 threshold_index = Some(per_epoch.len());
1922 }
1923 per_epoch.push(DriftEntry {
1924 epoch: s.epoch,
1925 error_m: err,
1926 });
1927 }
1928
1929 if per_epoch.is_empty() {
1930 return Err(PiecewiseOrbitError::TooFewSamples {
1931 got: 0,
1932 required: 1,
1933 });
1934 }
1935
1936 let rms_m = (sumsq / per_epoch.len() as f64).sqrt();
1937 Ok(DriftReport {
1938 per_epoch,
1939 max_m,
1940 rms_m,
1941 threshold_horizon,
1942 threshold_index,
1943 })
1944}
1945
1946#[cfg(all(test, sidereon_repo_tests))]
1947mod tests;