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::Tdb
1303 | TimeScale::Gpst
1304 | TimeScale::Gst
1305 | TimeScale::Bdt
1306 | TimeScale::Qzsst => validate::CivilSecondPolicy::Continuous,
1307 };
1308 validate::civil_datetime_with_second_policy(
1309 i64::from(epoch.year),
1310 i64::from(epoch.month),
1311 i64::from(epoch.day),
1312 i64::from(epoch.hour),
1313 i64::from(epoch.minute),
1314 epoch.second,
1315 second_policy,
1316 )
1317 .map(|_| ())
1318 .map_err(|_| invalid_input(field, "invalid calendar epoch"))
1319}
1320
1321fn validate_fit_epochs(samples: &[EcefSample], scale: TimeScale) -> Result<(), ReducedOrbitError> {
1322 for sample in samples {
1323 validate_calendar_epoch(sample.epoch, scale, "epoch")?;
1324 validate_finite(sample.x_m, "sample.x_m")?;
1325 validate_finite(sample.y_m, "sample.y_m")?;
1326 validate_finite(sample.z_m, "sample.z_m")?;
1327 }
1328 Ok(())
1329}
1330
1331fn validate_truth_sample(sample: &EcefSample, scale: TimeScale) -> Result<(), ReducedOrbitError> {
1332 validate_calendar_epoch(sample.epoch, scale, "truth.epoch")?;
1333 validate_finite(sample.x_m, "truth.x_m")?;
1334 validate_finite(sample.y_m, "truth.y_m")?;
1335 validate_finite(sample.z_m, "truth.z_m")
1336}
1337
1338fn validate_vec3(value: [f64; 3], field: &'static str) -> Result<(), ReducedOrbitError> {
1339 for component in value {
1340 validate_finite(component, field)?;
1341 }
1342 Ok(())
1343}
1344
1345fn validate_finite(value: f64, field: &'static str) -> Result<(), ReducedOrbitError> {
1346 if value.is_finite() {
1347 Ok(())
1348 } else {
1349 Err(invalid_input(field, "must be finite"))
1350 }
1351}
1352
1353fn validate_positive(value: f64, field: &'static str) -> Result<(), ReducedOrbitError> {
1354 validate_finite(value, field)?;
1355 if value > 0.0 {
1356 Ok(())
1357 } else {
1358 Err(invalid_input(field, "must be positive"))
1359 }
1360}
1361
1362fn invalid_input(field: &'static str, reason: &'static str) -> ReducedOrbitError {
1363 ReducedOrbitError::InvalidInput { field, reason }
1364}
1365
1366pub fn drift(
1377 elements: &Elements,
1378 truth: &[EcefSample],
1379 scale: TimeScale,
1380 threshold_m: f64,
1381) -> Result<DriftReport, ReducedOrbitError> {
1382 validate_elements_for_evaluation(elements, scale)?;
1383 validate_finite(threshold_m, "threshold_m")?;
1384 let mut per_epoch = Vec::with_capacity(truth.len());
1385 let mut sumsq = 0.0;
1386 let mut max_m = 0.0_f64;
1387 let mut threshold_horizon = None;
1388 let mut threshold_index = None;
1389
1390 for s in truth {
1391 validate_truth_sample(s, scale)?;
1392 let model = position(elements, s.epoch, scale, Frame::Ecef)?;
1393 let dx = model[0] - s.x_m;
1394 let dy = model[1] - s.y_m;
1395 let dz = model[2] - s.z_m;
1396 let err = (dx * dx + dy * dy + dz * dz).sqrt();
1397 validate_finite(err, "drift.error_m")?;
1398 sumsq += err * err;
1399 validate_finite(sumsq, "drift.sumsq")?;
1400 if err > max_m {
1401 max_m = err;
1402 }
1403 if threshold_horizon.is_none() && err > threshold_m {
1404 threshold_horizon = Some(s.epoch);
1405 threshold_index = Some(per_epoch.len());
1406 }
1407 per_epoch.push(DriftEntry {
1408 epoch: s.epoch,
1409 error_m: err,
1410 });
1411 }
1412
1413 let rms_m = if per_epoch.is_empty() {
1414 0.0
1415 } else {
1416 (sumsq / per_epoch.len() as f64).sqrt()
1417 };
1418 validate_finite(max_m, "drift.max_m")?;
1419 validate_finite(rms_m, "drift.rms_m")?;
1420
1421 Ok(DriftReport {
1422 per_epoch,
1423 max_m,
1424 rms_m,
1425 threshold_horizon,
1426 threshold_index,
1427 })
1428}
1429
1430pub fn fit_reduced_orbit_source(
1432 source: ReducedOrbitSource<'_>,
1433 options: ReducedOrbitSourceFitOptions,
1434) -> Result<ReducedOrbitSourceFit, ReducedOrbitSourceError> {
1435 let sampled = sample_reduced_orbit_source(source, options.sampling)?;
1436 let orbit = fit_with_model(&sampled.samples, sampled.scale, options.model)
1437 .map_err(ReducedOrbitSourceError::Reduced)?;
1438 Ok(ReducedOrbitSourceFit {
1439 orbit,
1440 requested_samples: sampled.requested,
1441 })
1442}
1443
1444pub fn drift_reduced_orbit_source(
1446 elements: &Elements,
1447 source: ReducedOrbitSource<'_>,
1448 options: ReducedOrbitSourceDriftOptions,
1449) -> Result<ReducedOrbitSourceDrift, ReducedOrbitSourceError> {
1450 let sampled = sample_reduced_orbit_source(source, options.sampling)?;
1451 if sampled.samples.is_empty() {
1452 return Err(ReducedOrbitSourceError::TooFewSamples {
1453 got: 0,
1454 required: 1,
1455 });
1456 }
1457 let report = drift(
1458 elements,
1459 &sampled.samples,
1460 sampled.scale,
1461 options.threshold_m,
1462 )
1463 .map_err(ReducedOrbitSourceError::Reduced)?;
1464 Ok(ReducedOrbitSourceDrift {
1465 report,
1466 requested_samples: sampled.requested,
1467 })
1468}
1469
1470pub fn fit_piecewise_reduced_orbit_source(
1472 source: ReducedOrbitSource<'_>,
1473 options: PiecewiseOrbitSourceFitOptions,
1474) -> Result<PiecewiseOrbitSourceFit, ReducedOrbitSourceError> {
1475 let sampled = sample_reduced_orbit_source(source, options.sampling)?;
1476 let segment_s = rounded_segment_s(options.segment_s)?;
1477 let orbit = fit_piecewise(
1478 &sampled.samples,
1479 sampled.scale,
1480 options.model,
1481 options.sampling.t0,
1482 options.sampling.t1,
1483 segment_s,
1484 )
1485 .map_err(ReducedOrbitSourceError::Piecewise)?;
1486 Ok(PiecewiseOrbitSourceFit {
1487 orbit,
1488 requested_samples: sampled.requested,
1489 })
1490}
1491
1492pub fn drift_piecewise_reduced_orbit_source(
1494 piecewise: &PiecewiseOrbit,
1495 source: ReducedOrbitSource<'_>,
1496 options: ReducedOrbitSourceDriftOptions,
1497) -> Result<ReducedOrbitSourceDrift, ReducedOrbitSourceError> {
1498 let sampled = sample_reduced_orbit_source(source, options.sampling)?;
1499 if sampled.samples.is_empty() {
1500 return Err(ReducedOrbitSourceError::TooFewSamples {
1501 got: 0,
1502 required: 1,
1503 });
1504 }
1505 let report = piecewise_drift(
1506 piecewise,
1507 &sampled.samples,
1508 sampled.scale,
1509 options.threshold_m,
1510 )
1511 .map_err(ReducedOrbitSourceError::Piecewise)?;
1512 Ok(ReducedOrbitSourceDrift {
1513 report,
1514 requested_samples: sampled.requested,
1515 })
1516}
1517
1518#[derive(Debug)]
1519struct SourceSamples {
1520 samples: Vec<EcefSample>,
1521 requested: usize,
1522 scale: TimeScale,
1523}
1524
1525fn sample_reduced_orbit_source(
1526 source: ReducedOrbitSource<'_>,
1527 sampling: ReducedOrbitSourceSampling,
1528) -> Result<SourceSamples, ReducedOrbitSourceError> {
1529 let scale = match source {
1530 ReducedOrbitSource::Sp3 { product, .. } => product.header.time_scale,
1531 ReducedOrbitSource::Sgp4 { .. } => TimeScale::Utc,
1532 };
1533 let steps = source_steps(sampling, scale)?;
1534 let samples = match source {
1535 ReducedOrbitSource::Sp3 { product, satellite } => steps
1536 .iter()
1537 .filter_map(|epoch| sample_sp3_epoch(product, satellite, *epoch))
1538 .collect(),
1539 ReducedOrbitSource::Sgp4 { satellite } => steps
1540 .iter()
1541 .filter_map(|epoch| sample_sgp4_epoch(satellite, *epoch))
1542 .collect(),
1543 };
1544 Ok(SourceSamples {
1545 samples,
1546 requested: steps.len(),
1547 scale,
1548 })
1549}
1550
1551fn source_steps(
1552 sampling: ReducedOrbitSourceSampling,
1553 scale: TimeScale,
1554) -> Result<Vec<CalendarEpoch>, ReducedOrbitSourceError> {
1555 validate_calendar_epoch(sampling.t0, scale, "window.start")
1556 .map_err(ReducedOrbitSourceError::Reduced)?;
1557 validate_calendar_epoch(sampling.t1, scale, "window.end")
1558 .map_err(ReducedOrbitSourceError::Reduced)?;
1559 if !sampling.cadence_s.is_finite() || sampling.cadence_s <= 0.0 {
1560 return Err(ReducedOrbitSourceError::InvalidCadence);
1561 }
1562 let span_s = calendar_seconds(sampling.t1) - calendar_seconds(sampling.t0);
1563 if !span_s.is_finite() || span_s <= 0.0 {
1564 return Err(ReducedOrbitSourceError::InvalidWindow);
1565 }
1566 let count = (span_s / sampling.cadence_s).trunc();
1567 if !count.is_finite() || count < 0.0 {
1568 return Err(ReducedOrbitSourceError::InvalidWindow);
1569 }
1570 let start_s = calendar_seconds(sampling.t0);
1571 Ok((0..=count as usize)
1572 .map(|k| calendar_from_seconds(start_s + (k as f64 * sampling.cadence_s).round()))
1573 .collect())
1574}
1575
1576fn sample_sp3_epoch(
1577 product: &Sp3,
1578 satellite: GnssSatelliteId,
1579 epoch: CalendarEpoch,
1580) -> Option<EcefSample> {
1581 let instant = instant_from_calendar(epoch, product.header.time_scale).ok()?;
1582 let state = product.position(satellite, instant).ok()?;
1583 Some(EcefSample::new(
1584 epoch,
1585 state.position.x_m,
1586 state.position.y_m,
1587 state.position.z_m,
1588 ))
1589}
1590
1591fn sample_sgp4_epoch(satellite: &Satellite, epoch: CalendarEpoch) -> Option<EcefSample> {
1592 let prediction = satellite
1593 .propagate_jd(julian_date_from_calendar(epoch))
1594 .ok()?;
1595 let ts = epoch.time_scales(TimeScale::Utc);
1596 let (gcrs, _) = teme_to_gcrs_compute(
1597 &TemeStateKm {
1598 position_km: prediction.position,
1599 velocity_km_s: prediction.velocity,
1600 },
1601 &ts,
1602 false,
1603 )
1604 .ok()?;
1605 let (x_km, y_km, z_km) = gcrs_to_itrs_compute(gcrs.0, gcrs.1, gcrs.2, &ts, false).ok()?;
1606 Some(EcefSample::new(
1607 epoch,
1608 x_km * M_PER_KM,
1609 y_km * M_PER_KM,
1610 z_km * M_PER_KM,
1611 ))
1612}
1613
1614fn instant_from_calendar(
1615 epoch: CalendarEpoch,
1616 scale: TimeScale,
1617) -> Result<Instant, ReducedOrbitSourceError> {
1618 let (jd_whole, fraction) = split_julian_date(
1619 epoch.year,
1620 epoch.month,
1621 epoch.day,
1622 epoch.hour,
1623 epoch.minute,
1624 epoch.second,
1625 );
1626 let split = JulianDateSplit::new(jd_whole, fraction)
1627 .map_err(|_| ReducedOrbitSourceError::InvalidWindow)?;
1628 Ok(Instant::from_julian_date(scale, split))
1629}
1630
1631fn julian_date_from_calendar(epoch: CalendarEpoch) -> JulianDate {
1632 let (jd_whole, fraction) = split_julian_date(
1633 epoch.year,
1634 epoch.month,
1635 epoch.day,
1636 epoch.hour,
1637 epoch.minute,
1638 epoch.second,
1639 );
1640 JulianDate(jd_whole, fraction)
1641}
1642
1643fn rounded_segment_s(segment_s: f64) -> Result<i64, ReducedOrbitSourceError> {
1644 if !segment_s.is_finite() || segment_s <= 0.0 {
1645 return Err(ReducedOrbitSourceError::InvalidSegment);
1646 }
1647 let rounded = segment_s.round();
1648 if rounded < 1.0 || rounded > i64::MAX as f64 {
1649 return Err(ReducedOrbitSourceError::InvalidSegment);
1650 }
1651 Ok(rounded as i64)
1652}
1653
1654fn calendar_seconds(t: CalendarEpoch) -> f64 {
1664 julian_day_number(t.year, t.month, t.day) as f64 * SECONDS_PER_DAY
1665 + (t.hour as f64) * SECONDS_PER_HOUR
1666 + (t.minute as f64) * SECONDS_PER_MINUTE
1667 + t.second
1668}
1669
1670fn calendar_from_seconds(total_s: f64) -> CalendarEpoch {
1671 let mut jdn = (total_s / SECONDS_PER_DAY).floor() as i64;
1672 let mut sod = total_s - jdn as f64 * SECONDS_PER_DAY;
1673 if sod < 0.0 {
1674 jdn -= 1;
1675 sod += SECONDS_PER_DAY;
1676 }
1677 if sod >= SECONDS_PER_DAY {
1678 jdn += 1;
1679 sod -= SECONDS_PER_DAY;
1680 }
1681
1682 let (year, month, day) = civil_from_julian_day_number(jdn);
1683 let hour = (sod / SECONDS_PER_HOUR).floor() as i32;
1684 let rem = sod - hour as f64 * SECONDS_PER_HOUR;
1685 let minute = (rem / SECONDS_PER_MINUTE).floor() as i32;
1686 let second = rem - minute as f64 * SECONDS_PER_MINUTE;
1687
1688 CalendarEpoch::new(year as i32, month as i32, day as i32, hour, minute, second)
1689}
1690
1691fn calendar_add_seconds(t: CalendarEpoch, seconds: i64) -> CalendarEpoch {
1692 calendar_from_seconds(calendar_seconds(t) + seconds as f64)
1693}
1694
1695fn segment_bounds(
1696 t0: CalendarEpoch,
1697 t1: CalendarEpoch,
1698 segment_s: i64,
1699) -> Result<Vec<(CalendarEpoch, CalendarEpoch)>, PiecewiseOrbitError> {
1700 if segment_s < 1 {
1701 return Err(PiecewiseOrbitError::InvalidSegment);
1702 }
1703 if calendar_seconds(t1) <= calendar_seconds(t0) {
1704 return Err(PiecewiseOrbitError::Reduced(
1705 ReducedOrbitError::InvalidWindow,
1706 ));
1707 }
1708
1709 let mut bounds = Vec::new();
1710 let mut seg_t0 = t0;
1711 let end_s = calendar_seconds(t1);
1712 while calendar_seconds(seg_t0) < end_s {
1713 let mut seg_t1 = calendar_add_seconds(seg_t0, segment_s);
1714 if calendar_seconds(seg_t1) > end_s {
1715 seg_t1 = t1;
1716 }
1717 bounds.push((seg_t0, seg_t1));
1718 seg_t0 = seg_t1;
1719 }
1720 Ok(bounds)
1721}
1722
1723fn is_too_few_or_invalid_window(e: &ReducedOrbitError) -> bool {
1724 matches!(
1725 e,
1726 ReducedOrbitError::TooFewSamples { .. } | ReducedOrbitError::InvalidWindow
1727 )
1728}
1729
1730fn sample_in_bounds(sample: CalendarEpoch, t0: CalendarEpoch, t1: CalendarEpoch) -> bool {
1731 let s = calendar_seconds(sample);
1732 s >= calendar_seconds(t0) && s <= calendar_seconds(t1)
1733}
1734
1735pub fn fit_piecewise(
1744 samples: &[EcefSample],
1745 scale: TimeScale,
1746 model: Model,
1747 t0: CalendarEpoch,
1748 t1: CalendarEpoch,
1749 segment_s: i64,
1750) -> Result<PiecewiseOrbit, PiecewiseOrbitError> {
1751 let bounds = segment_bounds(t0, t1, segment_s)?;
1752 let last_index = bounds.len().saturating_sub(1);
1753 let mut segments = Vec::new();
1754
1755 for (index, (seg_t0, seg_t1)) in bounds.into_iter().enumerate() {
1756 let subset: Vec<EcefSample> = samples
1757 .iter()
1758 .copied()
1759 .filter(|s| sample_in_bounds(s.epoch, seg_t0, seg_t1))
1760 .collect();
1761
1762 match fit_with_model(&subset, scale, model) {
1763 Ok(orbit) => segments.push(PiecewiseSegment {
1764 t0: seg_t0,
1765 t1: seg_t1,
1766 orbit,
1767 }),
1768 Err(e) if index == last_index && is_too_few_or_invalid_window(&e) => {}
1769 Err(e) => return Err(PiecewiseOrbitError::Reduced(e)),
1770 }
1771 }
1772
1773 let Some(last) = segments.last() else {
1774 return Err(PiecewiseOrbitError::TooFewSamples {
1775 got: 0,
1776 required: MIN_SAMPLES,
1777 });
1778 };
1779
1780 Ok(PiecewiseOrbit {
1781 model,
1782 t0,
1783 t1: last.t1,
1784 segment_s,
1785 segments,
1786 })
1787}
1788
1789pub fn select_piecewise_segment(
1794 piecewise: &PiecewiseOrbit,
1795 epoch: CalendarEpoch,
1796) -> Result<&PiecewiseSegment, PiecewiseOrbitError> {
1797 validate_piecewise_segments(piecewise)?;
1798 let e = calendar_seconds(epoch);
1799 if e < calendar_seconds(piecewise.t0) || e > calendar_seconds(piecewise.t1) {
1800 return Err(PiecewiseOrbitError::OutOfRange);
1801 }
1802
1803 let Some((last, rest)) = piecewise.segments.split_last() else {
1804 return Err(PiecewiseOrbitError::OutOfRange);
1805 };
1806
1807 for seg in rest {
1808 if e >= calendar_seconds(seg.t0) && e < calendar_seconds(seg.t1) {
1809 return Ok(seg);
1810 }
1811 }
1812
1813 if e >= calendar_seconds(last.t0) && e <= calendar_seconds(last.t1) {
1814 Ok(last)
1815 } else {
1816 Err(PiecewiseOrbitError::OutOfRange)
1817 }
1818}
1819
1820fn validate_piecewise_segments(piecewise: &PiecewiseOrbit) -> Result<(), PiecewiseOrbitError> {
1821 if piecewise.segment_s < 1 {
1822 return Err(PiecewiseOrbitError::InvalidSegment);
1823 }
1824 validate::require_strictly_increasing(
1825 piecewise
1826 .segments
1827 .iter()
1828 .map(|segment| calendar_seconds(segment.t0)),
1829 "piecewise.segments.t0",
1830 )
1831 .map_err(map_piecewise_order_error)?;
1832
1833 for segment in &piecewise.segments {
1834 validate::require_strictly_increasing(
1835 [calendar_seconds(segment.t0), calendar_seconds(segment.t1)],
1836 "piecewise.segment bounds",
1837 )
1838 .map_err(map_piecewise_order_error)?;
1839 }
1840 Ok(())
1841}
1842
1843fn map_piecewise_order_error(error: validate::FieldError) -> PiecewiseOrbitError {
1844 let reason = match error {
1845 validate::FieldError::NonFinite { .. } => "must be finite",
1846 validate::FieldError::OutOfRange { .. } => "must be strictly increasing",
1847 _ => error.reason(),
1848 };
1849 PiecewiseOrbitError::Reduced(invalid_input(error.field(), reason))
1850}
1851
1852pub fn piecewise_position(
1854 piecewise: &PiecewiseOrbit,
1855 epoch: CalendarEpoch,
1856 scale: TimeScale,
1857 frame: Frame,
1858) -> Result<[f64; 3], PiecewiseOrbitError> {
1859 validate_calendar_epoch(epoch, scale, "epoch").map_err(PiecewiseOrbitError::Reduced)?;
1860 let seg = select_piecewise_segment(piecewise, epoch)?;
1861 position(&seg.orbit.elements, epoch, scale, frame).map_err(PiecewiseOrbitError::Reduced)
1862}
1863
1864pub fn piecewise_position_velocity(
1866 piecewise: &PiecewiseOrbit,
1867 epoch: CalendarEpoch,
1868 scale: TimeScale,
1869 frame: Frame,
1870) -> Result<([f64; 3], [f64; 3]), PiecewiseOrbitError> {
1871 validate_calendar_epoch(epoch, scale, "epoch").map_err(PiecewiseOrbitError::Reduced)?;
1872 let seg = select_piecewise_segment(piecewise, epoch)?;
1873 position_velocity(&seg.orbit.elements, epoch, scale, frame)
1874 .map_err(PiecewiseOrbitError::Reduced)
1875}
1876
1877pub fn piecewise_drift(
1882 piecewise: &PiecewiseOrbit,
1883 truth: &[EcefSample],
1884 scale: TimeScale,
1885 threshold_m: f64,
1886) -> Result<DriftReport, PiecewiseOrbitError> {
1887 validate_finite(threshold_m, "threshold_m").map_err(PiecewiseOrbitError::Reduced)?;
1888 if truth.is_empty() {
1889 return Ok(DriftReport {
1890 per_epoch: Vec::new(),
1891 max_m: 0.0,
1892 rms_m: 0.0,
1893 threshold_horizon: None,
1894 threshold_index: None,
1895 });
1896 }
1897
1898 let mut per_epoch = Vec::with_capacity(truth.len());
1899 let mut sumsq = 0.0;
1900 let mut max_m = 0.0_f64;
1901 let mut threshold_horizon = None;
1902 let mut threshold_index = None;
1903
1904 for s in truth {
1905 validate_truth_sample(s, scale).map_err(PiecewiseOrbitError::Reduced)?;
1906 let Ok(model) = piecewise_position(piecewise, s.epoch, scale, Frame::Ecef) else {
1907 continue;
1908 };
1909 let dx = model[0] - s.x_m;
1910 let dy = model[1] - s.y_m;
1911 let dz = model[2] - s.z_m;
1912 let err = (dx * dx + dy * dy + dz * dz).sqrt();
1913 sumsq += err * err;
1914 if err > max_m {
1915 max_m = err;
1916 }
1917 if threshold_horizon.is_none() && err > threshold_m {
1918 threshold_horizon = Some(s.epoch);
1919 threshold_index = Some(per_epoch.len());
1920 }
1921 per_epoch.push(DriftEntry {
1922 epoch: s.epoch,
1923 error_m: err,
1924 });
1925 }
1926
1927 if per_epoch.is_empty() {
1928 return Err(PiecewiseOrbitError::TooFewSamples {
1929 got: 0,
1930 required: 1,
1931 });
1932 }
1933
1934 let rms_m = (sumsq / per_epoch.len() as f64).sqrt();
1935 Ok(DriftReport {
1936 per_epoch,
1937 max_m,
1938 rms_m,
1939 threshold_horizon,
1940 threshold_index,
1941 })
1942}
1943
1944#[cfg(all(test, sidereon_repo_tests))]
1945mod tests;