1use crate::astro::constants::time::SECONDS_PER_DAY;
98use crate::astro::constants::{J2_EARTH, MU_EARTH, RE_EARTH};
99use crate::astro::frames::transforms::{
100 gcrs_to_itrs_matrix, itrs_to_gcrs_compute, mat3_vec3_mul_unchecked,
101};
102use crate::astro::math::least_squares::{
103 self, solve_trf, LeastSquaresProblem, SolveOptions, Status,
104};
105use crate::astro::math::vec3::{cross3_ref as cross, norm3_ref as norm};
106use crate::astro::time::model::TimeScale;
107use crate::astro::time::scales::{julian_day_number, TimeScales};
108use nalgebra::DVector;
109
110use crate::constants::{M_PER_KM, OMEGA_E_DOT_RAD_S};
111use crate::tolerances::{
112 ECCENTRICITY_ZERO_EPS, REDUCED_ORBIT_KEPLER_STEP_EPS_RAD, REDUCED_ORBIT_SOLVER_TOL,
113 VECTOR_NORM_ZERO_EPS,
114};
115use crate::validate;
116
117mod time;
118use time::dt_seconds;
119pub use time::CalendarEpoch;
120
121pub const MIN_SAMPLES: usize = 4;
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
129pub enum Model {
130 #[default]
133 CircularSecular,
134 EccentricSecular,
137}
138
139#[derive(Debug, Clone, Copy)]
142pub struct EcefSample {
143 pub epoch: CalendarEpoch,
145 pub x_m: f64,
147 pub y_m: f64,
149 pub z_m: f64,
151}
152
153impl EcefSample {
154 pub const fn new(epoch: CalendarEpoch, x_m: f64, y_m: f64, z_m: f64) -> Self {
156 Self {
157 epoch,
158 x_m,
159 y_m,
160 z_m,
161 }
162 }
163}
164
165#[derive(Debug, Clone, Copy, PartialEq)]
171pub struct Elements {
172 pub model: Model,
174 pub epoch: CalendarEpoch,
176 pub a_m: f64,
178 pub e: f64,
181 pub i_rad: f64,
183 pub raan_rad: f64,
185 pub raan_rate_rad_s: f64,
187 pub raan_rate_j2_rad_s: f64,
189 pub arg_lat_rad: f64,
193 pub mean_motion_rad_s: f64,
195 pub h: f64,
197 pub k: f64,
199 pub arg_perigee_rad: f64,
202}
203
204#[derive(Debug, Clone, Copy, PartialEq)]
206pub struct FitStats {
207 pub rms_m: f64,
209 pub max_m: f64,
211 pub n_samples: usize,
213}
214
215#[derive(Debug, Clone, Copy, PartialEq)]
217pub struct ReducedOrbit {
218 pub elements: Elements,
220 pub stats: FitStats,
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226pub enum Frame {
227 Gcrs,
229 Ecef,
231}
232
233#[derive(Debug, Clone, Copy, PartialEq)]
235pub struct DriftEntry {
236 pub epoch: CalendarEpoch,
238 pub error_m: f64,
240}
241
242#[derive(Debug, Clone)]
244pub struct DriftReport {
245 pub per_epoch: Vec<DriftEntry>,
247 pub max_m: f64,
249 pub rms_m: f64,
251 pub threshold_horizon: Option<CalendarEpoch>,
254}
255
256#[derive(Debug, Clone, PartialEq)]
258pub struct PiecewiseSegment {
259 pub t0: CalendarEpoch,
261 pub t1: CalendarEpoch,
263 pub orbit: ReducedOrbit,
265}
266
267#[derive(Debug, Clone, PartialEq)]
269pub struct PiecewiseOrbit {
270 pub model: Model,
272 pub t0: CalendarEpoch,
274 pub t1: CalendarEpoch,
276 pub segment_s: i64,
278 pub segments: Vec<PiecewiseSegment>,
280}
281
282#[derive(Debug, Clone)]
284pub enum ReducedOrbitError {
285 TooFewSamples {
287 got: usize,
289 required: usize,
291 },
292 InvalidWindow,
295 SingularPlaneFit,
298 RaanAmbiguous,
301 Singular(least_squares::SolveError),
303 FitDidNotConverge,
306 InvalidInput {
308 field: &'static str,
310 reason: &'static str,
312 },
313}
314
315impl core::fmt::Display for ReducedOrbitError {
316 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
317 match self {
318 ReducedOrbitError::TooFewSamples { got, required } => {
319 write!(f, "only {got} samples; need at least {required}")
320 }
321 ReducedOrbitError::InvalidWindow => {
322 write!(f, "the fit window is empty, inverted, or has no time span")
323 }
324 ReducedOrbitError::SingularPlaneFit => {
325 write!(
326 f,
327 "samples are collinear or coincident; orbital plane undefined"
328 )
329 }
330 ReducedOrbitError::RaanAmbiguous => {
331 write!(f, "near-equatorial orbit; ascending node (raan) undefined")
332 }
333 ReducedOrbitError::Singular(e) => write!(f, "degenerate fit geometry: {e}"),
334 ReducedOrbitError::FitDidNotConverge => {
335 write!(f, "least-squares refinement did not converge")
336 }
337 ReducedOrbitError::InvalidInput { field, reason } => {
338 write!(f, "invalid reduced-orbit input {field}: {reason}")
339 }
340 }
341 }
342}
343
344impl std::error::Error for ReducedOrbitError {}
345
346impl From<least_squares::SolveError> for ReducedOrbitError {
347 fn from(e: least_squares::SolveError) -> Self {
348 ReducedOrbitError::Singular(e)
349 }
350}
351
352#[derive(Debug, Clone)]
354pub enum PiecewiseOrbitError {
355 InvalidSegment,
357 OutOfRange,
359 TooFewSamples {
361 got: usize,
363 required: usize,
365 },
366 Reduced(ReducedOrbitError),
368}
369
370impl From<ReducedOrbitError> for PiecewiseOrbitError {
371 fn from(e: ReducedOrbitError) -> Self {
372 PiecewiseOrbitError::Reduced(e)
373 }
374}
375
376const MIN_INCLINATION_RAD: f64 = 1.0e-3;
379
380const PLANE_NORMAL_SINGULAR_REL_EPS: f64 = 1.0e-9;
385
386const N_PARAMS: usize = 6;
393
394fn unscale_params(x: &[f64], a_scale: f64, rate_scale: f64) -> [f64; N_PARAMS] {
399 [
400 x[0] * a_scale,
401 x[1],
402 x[2],
403 x[3] / rate_scale,
404 x[4],
405 x[5] / rate_scale,
406 ]
407}
408
409const N_PARAMS_ECC: usize = 8;
412
413fn unscale_params_ecc(x: &[f64], a_scale: f64, rate_scale: f64) -> [f64; N_PARAMS_ECC] {
419 [
420 x[0] * a_scale,
421 x[1],
422 x[2],
423 x[3] / rate_scale,
424 x[4],
425 x[5],
426 x[6],
427 x[7] / rate_scale,
428 ]
429}
430
431fn solve_kepler(m: f64, e: f64) -> f64 {
436 let two_pi = 2.0 * std::f64::consts::PI;
437 let m = m.rem_euclid(two_pi);
438 let mut ee = if e < 0.8 { m } else { std::f64::consts::PI };
439 for _ in 0..30 {
440 let f = ee - e * ee.sin() - m;
441 let fp = 1.0 - e * ee.cos();
442 let d = f / fp;
443 ee -= d;
444 if d.abs() < REDUCED_ORBIT_KEPLER_STEP_EPS_RAD {
445 break;
446 }
447 }
448 ee
449}
450
451fn eval_gcrs_km_ecc(p: &[f64], dt: f64) -> [f64; 3] {
454 let a = p[0];
455 let i = p[1];
456 let raan = p[2] + p[3] * dt;
457 let h = p[4];
458 let k = p[5];
459 let lambda = p[6] + p[7] * dt;
460
461 let e = (h * h + k * k).sqrt();
462 if e < ECCENTRICITY_ZERO_EPS {
463 return eval_gcrs_km(&[a, i, p[2], p[3], p[6], p[7]], dt);
465 }
466 let omega = h.atan2(k);
467 let mm = lambda - omega;
468 let big_e = solve_kepler(mm, e);
469 let (se, ce) = big_e.sin_cos();
470 let r = a * (1.0 - e * ce);
471 let nu = (((1.0 - e * e).sqrt()) * se).atan2(ce - e);
472 let u = omega + nu;
473
474 rotate_in_plane_km([r * u.cos(), r * u.sin()], i, raan)
475}
476
477fn rotate_in_plane_km(xy: [f64; 2], i: f64, raan: f64) -> [f64; 3] {
480 let (si, ci) = i.sin_cos();
481 let (sr, cr) = raan.sin_cos();
482 let x1 = xy[0];
483 let y1 = xy[1] * ci;
484 let z1 = xy[1] * si;
485 [cr * x1 - sr * y1, sr * x1 + cr * y1, z1]
486}
487
488fn eval_gcrs_velocity_km_s_ecc(p: &[f64], dt: f64) -> [f64; 3] {
492 let a = p[0];
493 let i = p[1];
494 let raan_rate = p[3];
495 let h = p[4];
496 let k = p[5];
497 let n = p[7];
498 let raan = p[2] + raan_rate * dt;
499 let lambda = p[6] + n * dt;
500
501 let e = (h * h + k * k).sqrt();
502 if e < ECCENTRICITY_ZERO_EPS {
503 return eval_gcrs_velocity_km_s(&[a, i, p[2], p[3], p[6], p[7]], dt);
504 }
505 let omega = h.atan2(k);
506 let mm = lambda - omega;
507 let big_e = solve_kepler(mm, e);
508 let (se, ce) = big_e.sin_cos();
509 let edot = n / (1.0 - e * ce);
511 let beta = (1.0 - e * e).sqrt();
512
513 let x_pf = a * (ce - e);
515 let y_pf = a * beta * se;
516 let xdot_pf = -a * se * edot;
517 let ydot_pf = a * beta * ce * edot;
518
519 let (so, co) = omega.sin_cos();
521 let x1 = co * x_pf - so * y_pf;
522 let y1 = so * x_pf + co * y_pf;
523 let dx1 = co * xdot_pf - so * ydot_pf;
524 let dy1 = so * xdot_pf + co * ydot_pf;
525
526 let (si, ci) = i.sin_cos();
529 let (sr, cr) = raan.sin_cos();
530 let y1i = y1 * ci;
531 let dy1i = dy1 * ci;
532
533 let vx = cr * dx1 - sr * dy1i + raan_rate * (-sr * x1 - cr * y1i);
534 let vy = sr * dx1 + cr * dy1i + raan_rate * (cr * x1 - sr * y1i);
535 let vz = dy1 * si;
536 [vx, vy, vz]
537}
538
539fn eval_gcrs_km(p: &[f64], dt: f64) -> [f64; 3] {
542 let a = p[0];
543 let i = p[1];
544 let raan = p[2] + p[3] * dt;
545 let u = p[4] + p[5] * dt;
546
547 let (su, cu) = u.sin_cos();
549 let xp = a * cu;
550 let yp = a * su;
551
552 let (si, ci) = i.sin_cos();
554 let (sr, cr) = raan.sin_cos();
555
556 let x1 = xp;
558 let y1 = yp * ci;
559 let z1 = yp * si;
560
561 [cr * x1 - sr * y1, sr * x1 + cr * y1, z1]
563}
564
565fn eval_gcrs_velocity_km_s(p: &[f64], dt: f64) -> [f64; 3] {
568 let a = p[0];
569 let i = p[1];
570 let raan_rate = p[3];
571 let n = p[5];
572 let raan = p[2] + raan_rate * dt;
573 let u = p[4] + n * dt;
574
575 let (su, cu) = u.sin_cos();
576 let (si, ci) = i.sin_cos();
577 let (sr, cr) = raan.sin_cos();
578
579 let xp = a * cu;
580 let yp = a * su;
581 let dxp = -a * su * n;
583 let dyp = a * cu * n;
584
585 let x1 = xp;
587 let y1 = yp * ci;
588 let dx1 = dxp;
590 let dy1 = dyp * ci;
591
592 let vx = cr * dx1 - sr * dy1 + raan_rate * (-sr * x1 - cr * y1);
595 let vy = sr * dx1 + cr * dy1 + raan_rate * (cr * x1 - sr * y1);
596 let vz = dyp * si;
597 [vx, vy, vz]
598}
599
600struct GcrsSample {
605 dt: f64,
606 r_km: [f64; 3],
607}
608
609fn seed_params(samples: &[GcrsSample]) -> Result<[f64; N_PARAMS], ReducedOrbitError> {
611 let a_km = samples.iter().map(|s| norm(&s.r_km)).sum::<f64>() / samples.len() as f64;
613 if !a_km.is_finite() || a_km <= 0.0 {
614 return Err(ReducedOrbitError::SingularPlaneFit);
615 }
616
617 let mut h = [0.0_f64; 3];
619 for w in samples.windows(2) {
620 let c = cross(&w[0].r_km, &w[1].r_km);
621 h[0] += c[0];
622 h[1] += c[1];
623 h[2] += c[2];
624 }
625 let hn = norm(&h);
626 if !hn.is_finite() || hn <= a_km * a_km * PLANE_NORMAL_SINGULAR_REL_EPS {
627 return Err(ReducedOrbitError::SingularPlaneFit);
629 }
630 let hhat = [h[0] / hn, h[1] / hn, h[2] / hn];
631
632 let i = hhat[2].clamp(-1.0, 1.0).acos();
634 if i < MIN_INCLINATION_RAD || (std::f64::consts::PI - i) < MIN_INCLINATION_RAD {
635 return Err(ReducedOrbitError::RaanAmbiguous);
636 }
637
638 let node = [-hhat[1], hhat[0], 0.0];
640 let node_n = norm(&node);
641 if node_n <= VECTOR_NORM_ZERO_EPS {
642 return Err(ReducedOrbitError::RaanAmbiguous);
643 }
644 let raan0 = node[1].atan2(node[0]);
645 let nhat = [node[0] / node_n, node[1] / node_n, 0.0];
646
647 let r0 = &samples[0].r_km;
650 let cos_u = (r0[0] * nhat[0] + r0[1] * nhat[1] + r0[2] * nhat[2]) / norm(r0);
651 let p_axis = cross(&hhat, &nhat);
653 let sin_u = (r0[0] * p_axis[0] + r0[1] * p_axis[1] + r0[2] * p_axis[2]) / norm(r0);
654 let arg_lat0 = sin_u.atan2(cos_u);
655
656 let n = (MU_EARTH / (a_km * a_km * a_km)).sqrt();
658
659 let raan_rate = raan_rate_j2(n, i, a_km);
661
662 Ok([a_km, i, raan0, raan_rate, arg_lat0, n])
663}
664
665fn raan_rate_j2(n: f64, i: f64, a_km: f64) -> f64 {
668 let re_over_a = RE_EARTH / a_km;
669 -1.5 * n * J2_EARTH * re_over_a * re_over_a * i.cos()
670}
671
672pub fn fit(samples: &[EcefSample], scale: TimeScale) -> Result<ReducedOrbit, ReducedOrbitError> {
686 fit_with_model(samples, scale, Model::CircularSecular)
687}
688
689pub fn fit_with_model(
696 samples: &[EcefSample],
697 scale: TimeScale,
698 model: Model,
699) -> Result<ReducedOrbit, ReducedOrbitError> {
700 match model {
701 Model::CircularSecular => fit_circular(samples, scale),
702 Model::EccentricSecular => fit_eccentric(samples, scale),
703 }
704}
705
706fn fit_circular(
707 samples: &[EcefSample],
708 scale: TimeScale,
709) -> Result<ReducedOrbit, ReducedOrbitError> {
710 if samples.len() < MIN_SAMPLES {
711 return Err(ReducedOrbitError::TooFewSamples {
712 got: samples.len(),
713 required: MIN_SAMPLES,
714 });
715 }
716 validate_fit_epochs(samples, scale)?;
717
718 let mut ordered: Vec<(TimeScales, &EcefSample)> = samples
721 .iter()
722 .map(|s| (s.epoch.time_scales(scale), s))
723 .collect();
724 ordered.sort_by(|a, b| {
725 (a.0.jd_whole + a.0.tt_fraction).total_cmp(&(b.0.jd_whole + b.0.tt_fraction))
726 });
727
728 let t0_cal = ordered[0].1.epoch;
729 let t0_ts = ordered[0].0;
730
731 let mut gcrs: Vec<GcrsSample> = Vec::with_capacity(samples.len());
733 for (ts, s) in &ordered {
734 let (x, y, z) =
735 itrs_to_gcrs_compute(s.x_m / M_PER_KM, s.y_m / M_PER_KM, s.z_m / M_PER_KM, ts)
736 .expect("valid frame transform");
737 let dt = dt_seconds(&t0_ts, ts);
738 gcrs.push(GcrsSample {
739 dt,
740 r_km: [x, y, z],
741 });
742 }
743
744 let dt_span = gcrs.iter().map(|s| s.dt).fold(f64::NEG_INFINITY, f64::max)
746 - gcrs.iter().map(|s| s.dt).fold(f64::INFINITY, f64::min);
747 if !dt_span.is_finite() || dt_span <= 0.0 {
748 return Err(ReducedOrbitError::InvalidWindow);
749 }
750
751 let seed = seed_params(&gcrs)?;
752
753 let a_scale = seed[0];
761 let rate_scale = dt_span; let seed_scaled = [
763 seed[0] / a_scale,
764 seed[1],
765 seed[2],
766 seed[3] * rate_scale,
767 seed[4],
768 seed[5] * rate_scale,
769 ];
770
771 let m = 3 * gcrs.len();
773 let residual = {
774 let gcrs_ref: Vec<(f64, [f64; 3])> = gcrs.iter().map(|s| (s.dt, s.r_km)).collect();
775 move |x: &DVector<f64>| -> DVector<f64> {
776 let xs = x.as_slice();
777 let p = unscale_params(xs, a_scale, rate_scale);
778 let mut r = Vec::with_capacity(m);
779 for (dt, obs) in &gcrs_ref {
780 let model = eval_gcrs_km(&p, *dt);
781 r.push(model[0] - obs[0]);
782 r.push(model[1] - obs[1]);
783 r.push(model[2] - obs[2]);
784 }
785 DVector::from_vec(r)
786 }
787 };
788
789 let x0 = DVector::from_row_slice(&seed_scaled);
790 let problem = LeastSquaresProblem::new(residual, x0);
791 let opts = SolveOptions {
792 gtol: REDUCED_ORBIT_SOLVER_TOL,
793 ftol: REDUCED_ORBIT_SOLVER_TOL,
794 xtol: REDUCED_ORBIT_SOLVER_TOL,
795 max_nfev: 400,
796 };
797 let report = solve_trf(&problem, &opts)?;
798
799 let converged = matches!(
800 report.status,
801 Status::GradientTolerance | Status::CostTolerance | Status::StepTolerance
802 );
803 if !converged {
804 return Err(ReducedOrbitError::FitDidNotConverge);
805 }
806
807 let p = unscale_params(report.x.as_slice(), a_scale, rate_scale);
808 let res = &report.residual;
810 let n_samp = gcrs.len();
811 let mut sumsq = 0.0;
812 let mut maxsq = 0.0_f64;
813 for k in 0..n_samp {
814 let dx = res[3 * k] * M_PER_KM;
815 let dy = res[3 * k + 1] * M_PER_KM;
816 let dz = res[3 * k + 2] * M_PER_KM;
817 let e2 = dx * dx + dy * dy + dz * dz;
818 sumsq += e2;
819 if e2 > maxsq {
820 maxsq = e2;
821 }
822 }
823 let rms_m = (sumsq / n_samp as f64).sqrt();
824 let max_m = maxsq.sqrt();
825
826 let elements = Elements {
827 model: Model::CircularSecular,
828 epoch: t0_cal,
829 a_m: p[0] * M_PER_KM,
830 e: 0.0,
831 i_rad: p[1],
832 raan_rad: p[2],
833 raan_rate_rad_s: p[3],
834 raan_rate_j2_rad_s: raan_rate_j2(p[5], p[1], p[0]),
835 arg_lat_rad: p[4],
836 mean_motion_rad_s: p[5],
837 h: 0.0,
838 k: 0.0,
839 arg_perigee_rad: 0.0,
840 };
841
842 Ok(ReducedOrbit {
843 elements,
844 stats: FitStats {
845 rms_m,
846 max_m,
847 n_samples: n_samp,
848 },
849 })
850}
851
852fn to_gcrs_samples(
854 samples: &[EcefSample],
855 scale: TimeScale,
856) -> Result<(CalendarEpoch, Vec<GcrsSample>, f64), ReducedOrbitError> {
857 if samples.len() < MIN_SAMPLES {
858 return Err(ReducedOrbitError::TooFewSamples {
859 got: samples.len(),
860 required: MIN_SAMPLES,
861 });
862 }
863 validate_fit_epochs(samples, scale)?;
864
865 let mut ordered: Vec<(TimeScales, &EcefSample)> = samples
866 .iter()
867 .map(|s| (s.epoch.time_scales(scale), s))
868 .collect();
869 ordered.sort_by(|a, b| {
870 (a.0.jd_whole + a.0.tt_fraction).total_cmp(&(b.0.jd_whole + b.0.tt_fraction))
871 });
872
873 let t0_cal = ordered[0].1.epoch;
874 let t0_ts = ordered[0].0;
875
876 let mut gcrs: Vec<GcrsSample> = Vec::with_capacity(samples.len());
877 for (ts, s) in &ordered {
878 let (x, y, z) =
879 itrs_to_gcrs_compute(s.x_m / M_PER_KM, s.y_m / M_PER_KM, s.z_m / M_PER_KM, ts)
880 .expect("valid frame transform");
881 let dt = dt_seconds(&t0_ts, ts);
882 gcrs.push(GcrsSample {
883 dt,
884 r_km: [x, y, z],
885 });
886 }
887
888 let dt_span = gcrs.iter().map(|s| s.dt).fold(f64::NEG_INFINITY, f64::max)
889 - gcrs.iter().map(|s| s.dt).fold(f64::INFINITY, f64::min);
890 if !dt_span.is_finite() || dt_span <= 0.0 {
891 return Err(ReducedOrbitError::InvalidWindow);
892 }
893
894 Ok((t0_cal, gcrs, dt_span))
895}
896
897fn fit_eccentric(
901 samples: &[EcefSample],
902 scale: TimeScale,
903) -> Result<ReducedOrbit, ReducedOrbitError> {
904 let (t0_cal, gcrs, dt_span) = to_gcrs_samples(samples, scale)?;
905
906 let seed_c = seed_params(&gcrs)?;
908 let a_scale = seed_c[0];
909 let rate_scale = dt_span;
910
911 let seed_scaled = [
914 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, ];
923
924 let m = 3 * gcrs.len();
925 let residual = {
926 let gcrs_ref: Vec<(f64, [f64; 3])> = gcrs.iter().map(|s| (s.dt, s.r_km)).collect();
927 move |x: &DVector<f64>| -> DVector<f64> {
928 let xs = x.as_slice();
929 let p = unscale_params_ecc(xs, a_scale, rate_scale);
930 let mut r = Vec::with_capacity(m);
931 for (dt, obs) in &gcrs_ref {
932 let model = eval_gcrs_km_ecc(&p, *dt);
933 r.push(model[0] - obs[0]);
934 r.push(model[1] - obs[1]);
935 r.push(model[2] - obs[2]);
936 }
937 DVector::from_vec(r)
938 }
939 };
940
941 let x0 = DVector::from_row_slice(&seed_scaled);
942 let problem = LeastSquaresProblem::new(residual, x0);
943 let opts = SolveOptions {
944 gtol: REDUCED_ORBIT_SOLVER_TOL,
945 ftol: REDUCED_ORBIT_SOLVER_TOL,
946 xtol: REDUCED_ORBIT_SOLVER_TOL,
947 max_nfev: 400,
948 };
949 let report = solve_trf(&problem, &opts)?;
950
951 let converged = matches!(
952 report.status,
953 Status::GradientTolerance | Status::CostTolerance | Status::StepTolerance
954 );
955 if !converged {
956 return Err(ReducedOrbitError::FitDidNotConverge);
957 }
958
959 let p = unscale_params_ecc(report.x.as_slice(), a_scale, rate_scale);
960 let res = &report.residual;
961 let n_samp = gcrs.len();
962 let mut sumsq = 0.0;
963 let mut maxsq = 0.0_f64;
964 for k in 0..n_samp {
965 let dx = res[3 * k] * M_PER_KM;
966 let dy = res[3 * k + 1] * M_PER_KM;
967 let dz = res[3 * k + 2] * M_PER_KM;
968 let e2 = dx * dx + dy * dy + dz * dz;
969 sumsq += e2;
970 if e2 > maxsq {
971 maxsq = e2;
972 }
973 }
974 let rms_m = (sumsq / n_samp as f64).sqrt();
975 let max_m = maxsq.sqrt();
976
977 let h = p[4];
978 let k = p[5];
979 let e = (h * h + k * k).sqrt();
980 let arg_perigee_rad = if e < ECCENTRICITY_ZERO_EPS {
981 0.0
982 } else {
983 h.atan2(k)
984 };
985
986 let elements = Elements {
987 model: Model::EccentricSecular,
988 epoch: t0_cal,
989 a_m: p[0] * M_PER_KM,
990 e,
991 i_rad: p[1],
992 raan_rad: p[2],
993 raan_rate_rad_s: p[3],
994 raan_rate_j2_rad_s: raan_rate_j2(p[7], p[1], p[0]),
995 arg_lat_rad: p[6],
996 mean_motion_rad_s: p[7],
997 h,
998 k,
999 arg_perigee_rad,
1000 };
1001
1002 Ok(ReducedOrbit {
1003 elements,
1004 stats: FitStats {
1005 rms_m,
1006 max_m,
1007 n_samples: n_samp,
1008 },
1009 })
1010}
1011
1012fn params_from_elements(e: &Elements) -> [f64; N_PARAMS] {
1017 [
1018 e.a_m / M_PER_KM,
1019 e.i_rad,
1020 e.raan_rad,
1021 e.raan_rate_rad_s,
1022 e.arg_lat_rad,
1023 e.mean_motion_rad_s,
1024 ]
1025}
1026
1027fn params_from_elements_ecc(e: &Elements) -> [f64; N_PARAMS_ECC] {
1028 [
1029 e.a_m / M_PER_KM,
1030 e.i_rad,
1031 e.raan_rad,
1032 e.raan_rate_rad_s,
1033 e.h,
1034 e.k,
1035 e.arg_lat_rad,
1036 e.mean_motion_rad_s,
1037 ]
1038}
1039
1040fn eval_position_km(elements: &Elements, dt: f64) -> [f64; 3] {
1042 match elements.model {
1043 Model::CircularSecular => eval_gcrs_km(¶ms_from_elements(elements), dt),
1044 Model::EccentricSecular => eval_gcrs_km_ecc(¶ms_from_elements_ecc(elements), dt),
1045 }
1046}
1047
1048fn eval_velocity_km_s(elements: &Elements, dt: f64) -> [f64; 3] {
1050 match elements.model {
1051 Model::CircularSecular => eval_gcrs_velocity_km_s(¶ms_from_elements(elements), dt),
1052 Model::EccentricSecular => {
1053 eval_gcrs_velocity_km_s_ecc(¶ms_from_elements_ecc(elements), dt)
1054 }
1055 }
1056}
1057
1058pub fn position(
1061 elements: &Elements,
1062 epoch: CalendarEpoch,
1063 scale: TimeScale,
1064 frame: Frame,
1065) -> Result<[f64; 3], ReducedOrbitError> {
1066 validate_elements_for_evaluation(elements, scale)?;
1067 validate_calendar_epoch(epoch, scale, "epoch")?;
1068 let t0_ts = elements.epoch.time_scales(scale);
1069 let ts = epoch.time_scales(scale);
1070 let dt = dt_seconds(&t0_ts, &ts);
1071 validate_finite(dt, "dt_s")?;
1072 let r_gcrs_km = eval_position_km(elements, dt);
1073 let r = match frame {
1074 Frame::Gcrs => [
1075 r_gcrs_km[0] * M_PER_KM,
1076 r_gcrs_km[1] * M_PER_KM,
1077 r_gcrs_km[2] * M_PER_KM,
1078 ],
1079 Frame::Ecef => {
1080 let mat = gcrs_to_itrs_matrix(&ts)
1081 .map_err(|_| invalid_input("epoch", "invalid frame transform"))?;
1082 let r = mat3_vec3_mul_unchecked(&mat, &r_gcrs_km);
1083 [r[0] * M_PER_KM, r[1] * M_PER_KM, r[2] * M_PER_KM]
1084 }
1085 };
1086 validate_vec3(r, "position_m")?;
1087 Ok(r)
1088}
1089
1090pub fn position_velocity(
1094 elements: &Elements,
1095 epoch: CalendarEpoch,
1096 scale: TimeScale,
1097 frame: Frame,
1098) -> Result<([f64; 3], [f64; 3]), ReducedOrbitError> {
1099 validate_elements_for_evaluation(elements, scale)?;
1100 validate_calendar_epoch(epoch, scale, "epoch")?;
1101 let t0_ts = elements.epoch.time_scales(scale);
1102 let ts = epoch.time_scales(scale);
1103 let dt = dt_seconds(&t0_ts, &ts);
1104 validate_finite(dt, "dt_s")?;
1105 let r_gcrs_km = eval_position_km(elements, dt);
1106 let v_gcrs_km_s = eval_velocity_km_s(elements, dt);
1107
1108 let (r, v) = match frame {
1109 Frame::Gcrs => {
1110 let r = [
1111 r_gcrs_km[0] * M_PER_KM,
1112 r_gcrs_km[1] * M_PER_KM,
1113 r_gcrs_km[2] * M_PER_KM,
1114 ];
1115 let v = [
1116 v_gcrs_km_s[0] * M_PER_KM,
1117 v_gcrs_km_s[1] * M_PER_KM,
1118 v_gcrs_km_s[2] * M_PER_KM,
1119 ];
1120 (r, v)
1121 }
1122 Frame::Ecef => {
1123 let mat = gcrs_to_itrs_matrix(&ts)
1124 .map_err(|_| invalid_input("epoch", "invalid frame transform"))?;
1125 let r_itrs_km = mat3_vec3_mul_unchecked(&mat, &r_gcrs_km);
1126 let v_rot_km_s = mat3_vec3_mul_unchecked(&mat, &v_gcrs_km_s);
1127 let vx = v_rot_km_s[0] + OMEGA_E_DOT_RAD_S * r_itrs_km[1];
1129 let vy = v_rot_km_s[1] - OMEGA_E_DOT_RAD_S * r_itrs_km[0];
1130 let vz = v_rot_km_s[2];
1131 let r = [
1132 r_itrs_km[0] * M_PER_KM,
1133 r_itrs_km[1] * M_PER_KM,
1134 r_itrs_km[2] * M_PER_KM,
1135 ];
1136 let v = [vx * M_PER_KM, vy * M_PER_KM, vz * M_PER_KM];
1137 (r, v)
1138 }
1139 };
1140 validate_vec3(r, "position_m")?;
1141 validate_vec3(v, "velocity_m_s")?;
1142 Ok((r, v))
1143}
1144
1145fn validate_elements_for_evaluation(
1146 elements: &Elements,
1147 scale: TimeScale,
1148) -> Result<(), ReducedOrbitError> {
1149 validate_calendar_epoch(elements.epoch, scale, "elements.epoch")?;
1150 validate_positive(elements.a_m, "elements.a_m")?;
1151 validate_finite(elements.e, "elements.e")?;
1152 if !(0.0..1.0).contains(&elements.e) {
1153 return Err(invalid_input("elements.e", "must be in [0, 1)"));
1154 }
1155 validate_finite(elements.i_rad, "elements.i_rad")?;
1156 if !(0.0..=std::f64::consts::PI).contains(&elements.i_rad) {
1157 return Err(invalid_input("elements.i_rad", "must be in [0, pi]"));
1158 }
1159 validate_finite(elements.raan_rad, "elements.raan_rad")?;
1160 validate_finite(elements.raan_rate_rad_s, "elements.raan_rate_rad_s")?;
1161 validate_finite(elements.raan_rate_j2_rad_s, "elements.raan_rate_j2_rad_s")?;
1162 validate_finite(elements.arg_lat_rad, "elements.arg_lat_rad")?;
1163 validate_positive(elements.mean_motion_rad_s, "elements.mean_motion_rad_s")?;
1164 validate_finite(elements.h, "elements.h")?;
1165 validate_finite(elements.k, "elements.k")?;
1166 validate_finite(elements.arg_perigee_rad, "elements.arg_perigee_rad")?;
1167 if elements.model == Model::EccentricSecular {
1168 let derived_e = (elements.h * elements.h + elements.k * elements.k).sqrt();
1169 validate_finite(derived_e, "elements.h_k")?;
1170 if derived_e >= 1.0 {
1171 return Err(invalid_input("elements.h_k", "eccentricity must be < 1"));
1172 }
1173 }
1174 Ok(())
1175}
1176
1177fn validate_calendar_epoch(
1178 epoch: CalendarEpoch,
1179 scale: TimeScale,
1180 field: &'static str,
1181) -> Result<(), ReducedOrbitError> {
1182 let second_policy = match scale {
1183 TimeScale::Utc => validate::CivilSecondPolicy::UtcLike,
1184 TimeScale::Tai
1185 | TimeScale::Tt
1186 | TimeScale::Tdb
1187 | TimeScale::Gpst
1188 | TimeScale::Gst
1189 | TimeScale::Bdt => validate::CivilSecondPolicy::Continuous,
1190 };
1191 validate::civil_datetime_with_second_policy(
1192 i64::from(epoch.year),
1193 i64::from(epoch.month),
1194 i64::from(epoch.day),
1195 i64::from(epoch.hour),
1196 i64::from(epoch.minute),
1197 epoch.second,
1198 second_policy,
1199 )
1200 .map(|_| ())
1201 .map_err(|_| invalid_input(field, "invalid calendar epoch"))
1202}
1203
1204fn validate_fit_epochs(samples: &[EcefSample], scale: TimeScale) -> Result<(), ReducedOrbitError> {
1205 for sample in samples {
1206 validate_calendar_epoch(sample.epoch, scale, "epoch")?;
1207 validate_finite(sample.x_m, "sample.x_m")?;
1208 validate_finite(sample.y_m, "sample.y_m")?;
1209 validate_finite(sample.z_m, "sample.z_m")?;
1210 }
1211 Ok(())
1212}
1213
1214fn validate_truth_sample(sample: &EcefSample, scale: TimeScale) -> Result<(), ReducedOrbitError> {
1215 validate_calendar_epoch(sample.epoch, scale, "truth.epoch")?;
1216 validate_finite(sample.x_m, "truth.x_m")?;
1217 validate_finite(sample.y_m, "truth.y_m")?;
1218 validate_finite(sample.z_m, "truth.z_m")
1219}
1220
1221fn validate_vec3(value: [f64; 3], field: &'static str) -> Result<(), ReducedOrbitError> {
1222 for component in value {
1223 validate_finite(component, field)?;
1224 }
1225 Ok(())
1226}
1227
1228fn validate_finite(value: f64, field: &'static str) -> Result<(), ReducedOrbitError> {
1229 if value.is_finite() {
1230 Ok(())
1231 } else {
1232 Err(invalid_input(field, "must be finite"))
1233 }
1234}
1235
1236fn validate_positive(value: f64, field: &'static str) -> Result<(), ReducedOrbitError> {
1237 validate_finite(value, field)?;
1238 if value > 0.0 {
1239 Ok(())
1240 } else {
1241 Err(invalid_input(field, "must be positive"))
1242 }
1243}
1244
1245fn invalid_input(field: &'static str, reason: &'static str) -> ReducedOrbitError {
1246 ReducedOrbitError::InvalidInput { field, reason }
1247}
1248
1249pub fn drift(
1260 elements: &Elements,
1261 truth: &[EcefSample],
1262 scale: TimeScale,
1263 threshold_m: f64,
1264) -> Result<DriftReport, ReducedOrbitError> {
1265 validate_elements_for_evaluation(elements, scale)?;
1266 validate_finite(threshold_m, "threshold_m")?;
1267 let mut per_epoch = Vec::with_capacity(truth.len());
1268 let mut sumsq = 0.0;
1269 let mut max_m = 0.0_f64;
1270 let mut threshold_horizon = None;
1271
1272 for s in truth {
1273 validate_truth_sample(s, scale)?;
1274 let model = position(elements, s.epoch, scale, Frame::Ecef)?;
1275 let dx = model[0] - s.x_m;
1276 let dy = model[1] - s.y_m;
1277 let dz = model[2] - s.z_m;
1278 let err = (dx * dx + dy * dy + dz * dz).sqrt();
1279 validate_finite(err, "drift.error_m")?;
1280 sumsq += err * err;
1281 validate_finite(sumsq, "drift.sumsq")?;
1282 if err > max_m {
1283 max_m = err;
1284 }
1285 if threshold_horizon.is_none() && err > threshold_m {
1286 threshold_horizon = Some(s.epoch);
1287 }
1288 per_epoch.push(DriftEntry {
1289 epoch: s.epoch,
1290 error_m: err,
1291 });
1292 }
1293
1294 let rms_m = if per_epoch.is_empty() {
1295 0.0
1296 } else {
1297 (sumsq / per_epoch.len() as f64).sqrt()
1298 };
1299 validate_finite(max_m, "drift.max_m")?;
1300 validate_finite(rms_m, "drift.rms_m")?;
1301
1302 Ok(DriftReport {
1303 per_epoch,
1304 max_m,
1305 rms_m,
1306 threshold_horizon,
1307 })
1308}
1309
1310fn calendar_seconds(t: CalendarEpoch) -> f64 {
1315 julian_day_number(t.year, t.month, t.day) as f64 * SECONDS_PER_DAY
1316 + (t.hour as f64) * 3600.0
1317 + (t.minute as f64) * 60.0
1318 + t.second
1319}
1320
1321fn civil_from_jdn(jdn: i64) -> (i32, i32, i32) {
1322 let mut l = jdn + 68569;
1324 let n = (4 * l) / 146097;
1325 l -= (146097 * n + 3) / 4;
1326 let i = (4000 * (l + 1)) / 1461001;
1327 l = l - (1461 * i) / 4 + 31;
1328 let j = (80 * l) / 2447;
1329 let day = l - (2447 * j) / 80;
1330 l = j / 11;
1331 let month = j + 2 - 12 * l;
1332 let year = 100 * (n - 49) + i + l;
1333 (year as i32, month as i32, day as i32)
1334}
1335
1336fn calendar_from_seconds(total_s: f64) -> CalendarEpoch {
1337 let mut jdn = (total_s / SECONDS_PER_DAY).floor() as i64;
1338 let mut sod = total_s - jdn as f64 * SECONDS_PER_DAY;
1339 if sod < 0.0 {
1340 jdn -= 1;
1341 sod += SECONDS_PER_DAY;
1342 }
1343 if sod >= SECONDS_PER_DAY {
1344 jdn += 1;
1345 sod -= SECONDS_PER_DAY;
1346 }
1347
1348 let (year, month, day) = civil_from_jdn(jdn);
1349 let hour = (sod / 3600.0).floor() as i32;
1350 let rem = sod - hour as f64 * 3600.0;
1351 let minute = (rem / 60.0).floor() as i32;
1352 let second = rem - minute as f64 * 60.0;
1353
1354 CalendarEpoch::new(year, month, day, hour, minute, second)
1355}
1356
1357fn calendar_add_seconds(t: CalendarEpoch, seconds: i64) -> CalendarEpoch {
1358 calendar_from_seconds(calendar_seconds(t) + seconds as f64)
1359}
1360
1361fn segment_bounds(
1362 t0: CalendarEpoch,
1363 t1: CalendarEpoch,
1364 segment_s: i64,
1365) -> Result<Vec<(CalendarEpoch, CalendarEpoch)>, PiecewiseOrbitError> {
1366 if segment_s < 1 {
1367 return Err(PiecewiseOrbitError::InvalidSegment);
1368 }
1369 if calendar_seconds(t1) <= calendar_seconds(t0) {
1370 return Err(PiecewiseOrbitError::Reduced(
1371 ReducedOrbitError::InvalidWindow,
1372 ));
1373 }
1374
1375 let mut bounds = Vec::new();
1376 let mut seg_t0 = t0;
1377 let end_s = calendar_seconds(t1);
1378 while calendar_seconds(seg_t0) < end_s {
1379 let mut seg_t1 = calendar_add_seconds(seg_t0, segment_s);
1380 if calendar_seconds(seg_t1) > end_s {
1381 seg_t1 = t1;
1382 }
1383 bounds.push((seg_t0, seg_t1));
1384 seg_t0 = seg_t1;
1385 }
1386 Ok(bounds)
1387}
1388
1389fn is_too_few_or_invalid_window(e: &ReducedOrbitError) -> bool {
1390 matches!(
1391 e,
1392 ReducedOrbitError::TooFewSamples { .. } | ReducedOrbitError::InvalidWindow
1393 )
1394}
1395
1396fn sample_in_bounds(sample: CalendarEpoch, t0: CalendarEpoch, t1: CalendarEpoch) -> bool {
1397 let s = calendar_seconds(sample);
1398 s >= calendar_seconds(t0) && s <= calendar_seconds(t1)
1399}
1400
1401pub fn fit_piecewise(
1410 samples: &[EcefSample],
1411 scale: TimeScale,
1412 model: Model,
1413 t0: CalendarEpoch,
1414 t1: CalendarEpoch,
1415 segment_s: i64,
1416) -> Result<PiecewiseOrbit, PiecewiseOrbitError> {
1417 let bounds = segment_bounds(t0, t1, segment_s)?;
1418 let last_index = bounds.len().saturating_sub(1);
1419 let mut segments = Vec::new();
1420
1421 for (index, (seg_t0, seg_t1)) in bounds.into_iter().enumerate() {
1422 let subset: Vec<EcefSample> = samples
1423 .iter()
1424 .copied()
1425 .filter(|s| sample_in_bounds(s.epoch, seg_t0, seg_t1))
1426 .collect();
1427
1428 match fit_with_model(&subset, scale, model) {
1429 Ok(orbit) => segments.push(PiecewiseSegment {
1430 t0: seg_t0,
1431 t1: seg_t1,
1432 orbit,
1433 }),
1434 Err(e) if index == last_index && is_too_few_or_invalid_window(&e) => {}
1435 Err(e) => return Err(PiecewiseOrbitError::Reduced(e)),
1436 }
1437 }
1438
1439 let Some(last) = segments.last() else {
1440 return Err(PiecewiseOrbitError::TooFewSamples {
1441 got: 0,
1442 required: MIN_SAMPLES,
1443 });
1444 };
1445
1446 Ok(PiecewiseOrbit {
1447 model,
1448 t0,
1449 t1: last.t1,
1450 segment_s,
1451 segments,
1452 })
1453}
1454
1455pub fn select_piecewise_segment(
1460 piecewise: &PiecewiseOrbit,
1461 epoch: CalendarEpoch,
1462) -> Result<&PiecewiseSegment, PiecewiseOrbitError> {
1463 validate_piecewise_segments(piecewise)?;
1464 let e = calendar_seconds(epoch);
1465 if e < calendar_seconds(piecewise.t0) || e > calendar_seconds(piecewise.t1) {
1466 return Err(PiecewiseOrbitError::OutOfRange);
1467 }
1468
1469 let Some((last, rest)) = piecewise.segments.split_last() else {
1470 return Err(PiecewiseOrbitError::OutOfRange);
1471 };
1472
1473 for seg in rest {
1474 if e >= calendar_seconds(seg.t0) && e < calendar_seconds(seg.t1) {
1475 return Ok(seg);
1476 }
1477 }
1478
1479 if e >= calendar_seconds(last.t0) && e <= calendar_seconds(last.t1) {
1480 Ok(last)
1481 } else {
1482 Err(PiecewiseOrbitError::OutOfRange)
1483 }
1484}
1485
1486fn validate_piecewise_segments(piecewise: &PiecewiseOrbit) -> Result<(), PiecewiseOrbitError> {
1487 if piecewise.segment_s < 1 {
1488 return Err(PiecewiseOrbitError::InvalidSegment);
1489 }
1490 validate::require_strictly_increasing(
1491 piecewise
1492 .segments
1493 .iter()
1494 .map(|segment| calendar_seconds(segment.t0)),
1495 "piecewise.segments.t0",
1496 )
1497 .map_err(map_piecewise_order_error)?;
1498
1499 for segment in &piecewise.segments {
1500 validate::require_strictly_increasing(
1501 [calendar_seconds(segment.t0), calendar_seconds(segment.t1)],
1502 "piecewise.segment bounds",
1503 )
1504 .map_err(map_piecewise_order_error)?;
1505 }
1506 Ok(())
1507}
1508
1509fn map_piecewise_order_error(error: validate::FieldError) -> PiecewiseOrbitError {
1510 let reason = match error {
1511 validate::FieldError::NonFinite { .. } => "must be finite",
1512 validate::FieldError::OutOfRange { .. } => "must be strictly increasing",
1513 _ => error.reason(),
1514 };
1515 PiecewiseOrbitError::Reduced(invalid_input(error.field(), reason))
1516}
1517
1518pub fn piecewise_position(
1520 piecewise: &PiecewiseOrbit,
1521 epoch: CalendarEpoch,
1522 scale: TimeScale,
1523 frame: Frame,
1524) -> Result<[f64; 3], PiecewiseOrbitError> {
1525 validate_calendar_epoch(epoch, scale, "epoch").map_err(PiecewiseOrbitError::Reduced)?;
1526 let seg = select_piecewise_segment(piecewise, epoch)?;
1527 position(&seg.orbit.elements, epoch, scale, frame).map_err(PiecewiseOrbitError::Reduced)
1528}
1529
1530pub fn piecewise_position_velocity(
1532 piecewise: &PiecewiseOrbit,
1533 epoch: CalendarEpoch,
1534 scale: TimeScale,
1535 frame: Frame,
1536) -> Result<([f64; 3], [f64; 3]), PiecewiseOrbitError> {
1537 validate_calendar_epoch(epoch, scale, "epoch").map_err(PiecewiseOrbitError::Reduced)?;
1538 let seg = select_piecewise_segment(piecewise, epoch)?;
1539 position_velocity(&seg.orbit.elements, epoch, scale, frame)
1540 .map_err(PiecewiseOrbitError::Reduced)
1541}
1542
1543pub fn piecewise_drift(
1548 piecewise: &PiecewiseOrbit,
1549 truth: &[EcefSample],
1550 scale: TimeScale,
1551 threshold_m: f64,
1552) -> Result<DriftReport, PiecewiseOrbitError> {
1553 validate_finite(threshold_m, "threshold_m").map_err(PiecewiseOrbitError::Reduced)?;
1554 if truth.is_empty() {
1555 return Ok(DriftReport {
1556 per_epoch: Vec::new(),
1557 max_m: 0.0,
1558 rms_m: 0.0,
1559 threshold_horizon: None,
1560 });
1561 }
1562
1563 let mut per_epoch = Vec::with_capacity(truth.len());
1564 let mut sumsq = 0.0;
1565 let mut max_m = 0.0_f64;
1566 let mut threshold_horizon = None;
1567
1568 for s in truth {
1569 validate_truth_sample(s, scale).map_err(PiecewiseOrbitError::Reduced)?;
1570 let Ok(model) = piecewise_position(piecewise, s.epoch, scale, Frame::Ecef) else {
1571 continue;
1572 };
1573 let dx = model[0] - s.x_m;
1574 let dy = model[1] - s.y_m;
1575 let dz = model[2] - s.z_m;
1576 let err = (dx * dx + dy * dy + dz * dz).sqrt();
1577 sumsq += err * err;
1578 if err > max_m {
1579 max_m = err;
1580 }
1581 if threshold_horizon.is_none() && err > threshold_m {
1582 threshold_horizon = Some(s.epoch);
1583 }
1584 per_epoch.push(DriftEntry {
1585 epoch: s.epoch,
1586 error_m: err,
1587 });
1588 }
1589
1590 if per_epoch.is_empty() {
1591 return Err(PiecewiseOrbitError::TooFewSamples {
1592 got: 0,
1593 required: 1,
1594 });
1595 }
1596
1597 let rms_m = (sumsq / per_epoch.len() as f64).sqrt();
1598 Ok(DriftReport {
1599 per_epoch,
1600 max_m,
1601 rms_m,
1602 threshold_horizon,
1603 })
1604}
1605
1606#[cfg(all(test, sidereon_repo_tests))]
1607mod tests;