use crate::astro::constants::time::{SECONDS_PER_DAY, SECONDS_PER_HOUR, SECONDS_PER_MINUTE};
use crate::astro::constants::{J2_EARTH, MU_EARTH, RE_EARTH};
use crate::astro::frames::transforms::{
gcrs_to_itrs_compute, gcrs_to_itrs_matrix, itrs_to_gcrs_compute, mat3_vec3_mul_unchecked,
teme_to_gcrs_compute, TemeStateKm,
};
use crate::astro::math::least_squares::{
self, solve_trf, LeastSquaresProblem, SolveOptions, Status,
};
use crate::astro::math::vec3::{cross3_ref as cross, norm3_ref as norm};
use crate::astro::sgp4::{JulianDate, Satellite};
use crate::astro::time::civil::{civil_from_julian_day_number, split_julian_date};
use crate::astro::time::model::{Instant, JulianDateSplit, TimeScale};
use crate::astro::time::scales::{julian_day_number, TimeScales};
use nalgebra::DVector;
use crate::constants::{M_PER_KM, OMEGA_E_DOT_RAD_S};
use crate::sp3::Sp3;
use crate::tolerances::{
ECCENTRICITY_ZERO_EPS, REDUCED_ORBIT_KEPLER_STEP_EPS_RAD, REDUCED_ORBIT_SOLVER_TOL,
VECTOR_NORM_ZERO_EPS,
};
use crate::validate;
use crate::GnssSatelliteId;
mod time;
use time::dt_seconds;
pub use time::CalendarEpoch;
pub const MIN_SAMPLES: usize = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Model {
#[default]
CircularSecular,
EccentricSecular,
}
#[derive(Debug, Clone, Copy)]
pub struct EcefSample {
pub epoch: CalendarEpoch,
pub x_m: f64,
pub y_m: f64,
pub z_m: f64,
}
impl EcefSample {
pub const fn new(epoch: CalendarEpoch, x_m: f64, y_m: f64, z_m: f64) -> Self {
Self {
epoch,
x_m,
y_m,
z_m,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Elements {
pub model: Model,
pub epoch: CalendarEpoch,
pub a_m: f64,
pub e: f64,
pub i_rad: f64,
pub raan_rad: f64,
pub raan_rate_rad_s: f64,
pub raan_rate_j2_rad_s: f64,
pub arg_lat_rad: f64,
pub mean_motion_rad_s: f64,
pub h: f64,
pub k: f64,
pub arg_perigee_rad: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FitStats {
pub rms_m: f64,
pub max_m: f64,
pub n_samples: usize,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ReducedOrbit {
pub elements: Elements,
pub stats: FitStats,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Frame {
Gcrs,
Ecef,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DriftEntry {
pub epoch: CalendarEpoch,
pub error_m: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DriftReport {
pub per_epoch: Vec<DriftEntry>,
pub max_m: f64,
pub rms_m: f64,
pub threshold_horizon: Option<CalendarEpoch>,
pub threshold_index: Option<usize>,
}
#[derive(Debug, Clone, Copy)]
pub enum ReducedOrbitSource<'a> {
Sp3 {
product: &'a Sp3,
satellite: GnssSatelliteId,
},
Sgp4 { satellite: &'a Satellite },
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ReducedOrbitSourceSampling {
pub t0: CalendarEpoch,
pub t1: CalendarEpoch,
pub cadence_s: f64,
}
impl ReducedOrbitSourceSampling {
pub const fn new(t0: CalendarEpoch, t1: CalendarEpoch, cadence_s: f64) -> Self {
Self { t0, t1, cadence_s }
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ReducedOrbitSourceFitOptions {
pub sampling: ReducedOrbitSourceSampling,
pub model: Model,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ReducedOrbitSourceDriftOptions {
pub sampling: ReducedOrbitSourceSampling,
pub threshold_m: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PiecewiseOrbitSourceFitOptions {
pub sampling: ReducedOrbitSourceSampling,
pub model: Model,
pub segment_s: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ReducedOrbitSourceFit {
pub orbit: ReducedOrbit,
pub requested_samples: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ReducedOrbitSourceDrift {
pub report: DriftReport,
pub requested_samples: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PiecewiseOrbitSourceFit {
pub orbit: PiecewiseOrbit,
pub requested_samples: usize,
}
#[derive(Debug, Clone)]
pub enum ReducedOrbitSourceError {
InvalidWindow,
InvalidCadence,
InvalidSegment,
TooFewSamples { got: usize, required: usize },
Reduced(ReducedOrbitError),
Piecewise(PiecewiseOrbitError),
}
impl core::fmt::Display for ReducedOrbitSourceError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::InvalidWindow => write!(f, "invalid reduced-orbit source window"),
Self::InvalidCadence => write!(f, "invalid reduced-orbit source cadence"),
Self::InvalidSegment => write!(f, "invalid reduced-orbit source segment"),
Self::TooFewSamples { got, required } => {
write!(f, "only {got} source samples; need at least {required}")
}
Self::Reduced(error) => write!(f, "{error}"),
Self::Piecewise(error) => write!(f, "{error:?}"),
}
}
}
impl std::error::Error for ReducedOrbitSourceError {}
#[derive(Debug, Clone, PartialEq)]
pub struct PiecewiseSegment {
pub t0: CalendarEpoch,
pub t1: CalendarEpoch,
pub orbit: ReducedOrbit,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PiecewiseOrbit {
pub model: Model,
pub t0: CalendarEpoch,
pub t1: CalendarEpoch,
pub segment_s: i64,
pub segments: Vec<PiecewiseSegment>,
}
#[derive(Debug, Clone)]
pub enum ReducedOrbitError {
TooFewSamples {
got: usize,
required: usize,
},
InvalidWindow,
SingularPlaneFit,
RaanAmbiguous,
Singular(least_squares::SolveError),
FitDidNotConverge,
InvalidInput {
field: &'static str,
reason: &'static str,
},
}
impl core::fmt::Display for ReducedOrbitError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
ReducedOrbitError::TooFewSamples { got, required } => {
write!(f, "only {got} samples; need at least {required}")
}
ReducedOrbitError::InvalidWindow => {
write!(f, "the fit window is empty, inverted, or has no time span")
}
ReducedOrbitError::SingularPlaneFit => {
write!(
f,
"samples are collinear or coincident; orbital plane undefined"
)
}
ReducedOrbitError::RaanAmbiguous => {
write!(f, "near-equatorial orbit; ascending node (raan) undefined")
}
ReducedOrbitError::Singular(e) => write!(f, "degenerate fit geometry: {e}"),
ReducedOrbitError::FitDidNotConverge => {
write!(f, "least-squares refinement did not converge")
}
ReducedOrbitError::InvalidInput { field, reason } => {
write!(f, "invalid reduced-orbit input {field}: {reason}")
}
}
}
}
impl std::error::Error for ReducedOrbitError {}
impl From<least_squares::SolveError> for ReducedOrbitError {
fn from(e: least_squares::SolveError) -> Self {
ReducedOrbitError::Singular(e)
}
}
#[derive(Debug, Clone)]
pub enum PiecewiseOrbitError {
InvalidSegment,
OutOfRange,
TooFewSamples {
got: usize,
required: usize,
},
Reduced(ReducedOrbitError),
}
impl From<ReducedOrbitError> for PiecewiseOrbitError {
fn from(e: ReducedOrbitError) -> Self {
PiecewiseOrbitError::Reduced(e)
}
}
const MIN_INCLINATION_RAD: f64 = 1.0e-3;
const PLANE_NORMAL_SINGULAR_REL_EPS: f64 = 1.0e-9;
const N_PARAMS: usize = 6;
fn unscale_params(x: &[f64], a_scale: f64, rate_scale: f64) -> [f64; N_PARAMS] {
[
x[0] * a_scale,
x[1],
x[2],
x[3] / rate_scale,
x[4],
x[5] / rate_scale,
]
}
const N_PARAMS_ECC: usize = 8;
fn unscale_params_ecc(x: &[f64], a_scale: f64, rate_scale: f64) -> [f64; N_PARAMS_ECC] {
[
x[0] * a_scale,
x[1],
x[2],
x[3] / rate_scale,
x[4],
x[5],
x[6],
x[7] / rate_scale,
]
}
fn solve_kepler(m: f64, e: f64) -> f64 {
let two_pi = 2.0 * std::f64::consts::PI;
let m = m.rem_euclid(two_pi);
let mut ee = if e < 0.8 { m } else { std::f64::consts::PI };
for _ in 0..30 {
let f = ee - e * ee.sin() - m;
let fp = 1.0 - e * ee.cos();
let d = f / fp;
ee -= d;
if d.abs() < REDUCED_ORBIT_KEPLER_STEP_EPS_RAD {
break;
}
}
ee
}
fn eval_gcrs_km_ecc(p: &[f64], dt: f64) -> [f64; 3] {
let a = p[0];
let i = p[1];
let raan = p[2] + p[3] * dt;
let h = p[4];
let k = p[5];
let lambda = p[6] + p[7] * dt;
let e = (h * h + k * k).sqrt();
if e < ECCENTRICITY_ZERO_EPS {
return eval_gcrs_km(&[a, i, p[2], p[3], p[6], p[7]], dt);
}
let omega = h.atan2(k);
let mm = lambda - omega;
let big_e = solve_kepler(mm, e);
let (se, ce) = big_e.sin_cos();
let r = a * (1.0 - e * ce);
let nu = (((1.0 - e * e).sqrt()) * se).atan2(ce - e);
let u = omega + nu;
rotate_in_plane_km([r * u.cos(), r * u.sin()], i, raan)
}
fn rotate_in_plane_km(xy: [f64; 2], i: f64, raan: f64) -> [f64; 3] {
let (si, ci) = i.sin_cos();
let (sr, cr) = raan.sin_cos();
let x1 = xy[0];
let y1 = xy[1] * ci;
let z1 = xy[1] * si;
[cr * x1 - sr * y1, sr * x1 + cr * y1, z1]
}
fn eval_gcrs_velocity_km_s_ecc(p: &[f64], dt: f64) -> [f64; 3] {
let a = p[0];
let i = p[1];
let raan_rate = p[3];
let h = p[4];
let k = p[5];
let n = p[7];
let raan = p[2] + raan_rate * dt;
let lambda = p[6] + n * dt;
let e = (h * h + k * k).sqrt();
if e < ECCENTRICITY_ZERO_EPS {
return eval_gcrs_velocity_km_s(&[a, i, p[2], p[3], p[6], p[7]], dt);
}
let omega = h.atan2(k);
let mm = lambda - omega;
let big_e = solve_kepler(mm, e);
let (se, ce) = big_e.sin_cos();
let edot = n / (1.0 - e * ce);
let beta = (1.0 - e * e).sqrt();
let x_pf = a * (ce - e);
let y_pf = a * beta * se;
let xdot_pf = -a * se * edot;
let ydot_pf = a * beta * ce * edot;
let (so, co) = omega.sin_cos();
let x1 = co * x_pf - so * y_pf;
let y1 = so * x_pf + co * y_pf;
let dx1 = co * xdot_pf - so * ydot_pf;
let dy1 = so * xdot_pf + co * ydot_pf;
let (si, ci) = i.sin_cos();
let (sr, cr) = raan.sin_cos();
let y1i = y1 * ci;
let dy1i = dy1 * ci;
let vx = cr * dx1 - sr * dy1i + raan_rate * (-sr * x1 - cr * y1i);
let vy = sr * dx1 + cr * dy1i + raan_rate * (cr * x1 - sr * y1i);
let vz = dy1 * si;
[vx, vy, vz]
}
fn eval_gcrs_km(p: &[f64], dt: f64) -> [f64; 3] {
let a = p[0];
let i = p[1];
let raan = p[2] + p[3] * dt;
let u = p[4] + p[5] * dt;
let (su, cu) = u.sin_cos();
let xp = a * cu;
let yp = a * su;
let (si, ci) = i.sin_cos();
let (sr, cr) = raan.sin_cos();
let x1 = xp;
let y1 = yp * ci;
let z1 = yp * si;
[cr * x1 - sr * y1, sr * x1 + cr * y1, z1]
}
fn eval_gcrs_velocity_km_s(p: &[f64], dt: f64) -> [f64; 3] {
let a = p[0];
let i = p[1];
let raan_rate = p[3];
let n = p[5];
let raan = p[2] + raan_rate * dt;
let u = p[4] + n * dt;
let (su, cu) = u.sin_cos();
let (si, ci) = i.sin_cos();
let (sr, cr) = raan.sin_cos();
let xp = a * cu;
let yp = a * su;
let dxp = -a * su * n;
let dyp = a * cu * n;
let x1 = xp;
let y1 = yp * ci;
let dx1 = dxp;
let dy1 = dyp * ci;
let vx = cr * dx1 - sr * dy1 + raan_rate * (-sr * x1 - cr * y1);
let vy = sr * dx1 + cr * dy1 + raan_rate * (cr * x1 - sr * y1);
let vz = dyp * si;
[vx, vy, vz]
}
struct GcrsSample {
dt: f64,
r_km: [f64; 3],
}
fn seed_params(samples: &[GcrsSample]) -> Result<[f64; N_PARAMS], ReducedOrbitError> {
let a_km = samples.iter().map(|s| norm(&s.r_km)).sum::<f64>() / samples.len() as f64;
if !a_km.is_finite() || a_km <= 0.0 {
return Err(ReducedOrbitError::SingularPlaneFit);
}
let mut h = [0.0_f64; 3];
for w in samples.windows(2) {
let c = cross(&w[0].r_km, &w[1].r_km);
h[0] += c[0];
h[1] += c[1];
h[2] += c[2];
}
let hn = norm(&h);
if !hn.is_finite() || hn <= a_km * a_km * PLANE_NORMAL_SINGULAR_REL_EPS {
return Err(ReducedOrbitError::SingularPlaneFit);
}
let hhat = [h[0] / hn, h[1] / hn, h[2] / hn];
let i = hhat[2].clamp(-1.0, 1.0).acos();
if i < MIN_INCLINATION_RAD || (std::f64::consts::PI - i) < MIN_INCLINATION_RAD {
return Err(ReducedOrbitError::RaanAmbiguous);
}
let node = [-hhat[1], hhat[0], 0.0];
let node_n = norm(&node);
if node_n <= VECTOR_NORM_ZERO_EPS {
return Err(ReducedOrbitError::RaanAmbiguous);
}
let raan0 = node[1].atan2(node[0]);
let nhat = [node[0] / node_n, node[1] / node_n, 0.0];
let r0 = &samples[0].r_km;
let cos_u = (r0[0] * nhat[0] + r0[1] * nhat[1] + r0[2] * nhat[2]) / norm(r0);
let p_axis = cross(&hhat, &nhat);
let sin_u = (r0[0] * p_axis[0] + r0[1] * p_axis[1] + r0[2] * p_axis[2]) / norm(r0);
let arg_lat0 = sin_u.atan2(cos_u);
let n = (MU_EARTH / (a_km * a_km * a_km)).sqrt();
let raan_rate = raan_rate_j2(n, i, a_km);
Ok([a_km, i, raan0, raan_rate, arg_lat0, n])
}
fn raan_rate_j2(n: f64, i: f64, a_km: f64) -> f64 {
let re_over_a = RE_EARTH / a_km;
-1.5 * n * J2_EARTH * re_over_a * re_over_a * i.cos()
}
pub fn fit(samples: &[EcefSample], scale: TimeScale) -> Result<ReducedOrbit, ReducedOrbitError> {
fit_with_model(samples, scale, Model::CircularSecular)
}
pub fn fit_with_model(
samples: &[EcefSample],
scale: TimeScale,
model: Model,
) -> Result<ReducedOrbit, ReducedOrbitError> {
match model {
Model::CircularSecular => fit_circular(samples, scale),
Model::EccentricSecular => fit_eccentric(samples, scale),
}
}
fn fit_circular(
samples: &[EcefSample],
scale: TimeScale,
) -> Result<ReducedOrbit, ReducedOrbitError> {
if samples.len() < MIN_SAMPLES {
return Err(ReducedOrbitError::TooFewSamples {
got: samples.len(),
required: MIN_SAMPLES,
});
}
validate_fit_epochs(samples, scale)?;
let mut ordered: Vec<(TimeScales, &EcefSample)> = samples
.iter()
.map(|s| (s.epoch.time_scales(scale), s))
.collect();
ordered.sort_by(|a, b| {
(a.0.jd_whole + a.0.tt_fraction).total_cmp(&(b.0.jd_whole + b.0.tt_fraction))
});
let t0_cal = ordered[0].1.epoch;
let t0_ts = ordered[0].0;
let mut gcrs: Vec<GcrsSample> = Vec::with_capacity(samples.len());
for (ts, s) in &ordered {
let (x, y, z) =
itrs_to_gcrs_compute(s.x_m / M_PER_KM, s.y_m / M_PER_KM, s.z_m / M_PER_KM, ts)
.expect("valid frame transform");
let dt = dt_seconds(&t0_ts, ts);
gcrs.push(GcrsSample {
dt,
r_km: [x, y, z],
});
}
let dt_span = gcrs.iter().map(|s| s.dt).fold(f64::NEG_INFINITY, f64::max)
- gcrs.iter().map(|s| s.dt).fold(f64::INFINITY, f64::min);
if !dt_span.is_finite() || dt_span <= 0.0 {
return Err(ReducedOrbitError::InvalidWindow);
}
let seed = seed_params(&gcrs)?;
let a_scale = seed[0];
let rate_scale = dt_span; let seed_scaled = [
seed[0] / a_scale,
seed[1],
seed[2],
seed[3] * rate_scale,
seed[4],
seed[5] * rate_scale,
];
let m = 3 * gcrs.len();
let residual = {
let gcrs_ref: Vec<(f64, [f64; 3])> = gcrs.iter().map(|s| (s.dt, s.r_km)).collect();
move |x: &DVector<f64>| -> DVector<f64> {
let xs = x.as_slice();
let p = unscale_params(xs, a_scale, rate_scale);
let mut r = Vec::with_capacity(m);
for (dt, obs) in &gcrs_ref {
let model = eval_gcrs_km(&p, *dt);
r.push(model[0] - obs[0]);
r.push(model[1] - obs[1]);
r.push(model[2] - obs[2]);
}
DVector::from_vec(r)
}
};
let x0 = DVector::from_row_slice(&seed_scaled);
let problem = LeastSquaresProblem::new(residual, x0);
let opts = SolveOptions {
gtol: REDUCED_ORBIT_SOLVER_TOL,
ftol: REDUCED_ORBIT_SOLVER_TOL,
xtol: REDUCED_ORBIT_SOLVER_TOL,
max_nfev: 400,
};
let report = solve_trf(&problem, &opts)?;
let converged = matches!(
report.status,
Status::GradientTolerance | Status::CostTolerance | Status::StepTolerance
);
if !converged {
return Err(ReducedOrbitError::FitDidNotConverge);
}
let p = unscale_params(report.x.as_slice(), a_scale, rate_scale);
let res = &report.residual;
let n_samp = gcrs.len();
let mut sumsq = 0.0;
let mut maxsq = 0.0_f64;
for k in 0..n_samp {
let dx = res[3 * k] * M_PER_KM;
let dy = res[3 * k + 1] * M_PER_KM;
let dz = res[3 * k + 2] * M_PER_KM;
let e2 = dx * dx + dy * dy + dz * dz;
sumsq += e2;
if e2 > maxsq {
maxsq = e2;
}
}
let rms_m = (sumsq / n_samp as f64).sqrt();
let max_m = maxsq.sqrt();
let elements = Elements {
model: Model::CircularSecular,
epoch: t0_cal,
a_m: p[0] * M_PER_KM,
e: 0.0,
i_rad: p[1],
raan_rad: p[2],
raan_rate_rad_s: p[3],
raan_rate_j2_rad_s: raan_rate_j2(p[5], p[1], p[0]),
arg_lat_rad: p[4],
mean_motion_rad_s: p[5],
h: 0.0,
k: 0.0,
arg_perigee_rad: 0.0,
};
Ok(ReducedOrbit {
elements,
stats: FitStats {
rms_m,
max_m,
n_samples: n_samp,
},
})
}
fn to_gcrs_samples(
samples: &[EcefSample],
scale: TimeScale,
) -> Result<(CalendarEpoch, Vec<GcrsSample>, f64), ReducedOrbitError> {
if samples.len() < MIN_SAMPLES {
return Err(ReducedOrbitError::TooFewSamples {
got: samples.len(),
required: MIN_SAMPLES,
});
}
validate_fit_epochs(samples, scale)?;
let mut ordered: Vec<(TimeScales, &EcefSample)> = samples
.iter()
.map(|s| (s.epoch.time_scales(scale), s))
.collect();
ordered.sort_by(|a, b| {
(a.0.jd_whole + a.0.tt_fraction).total_cmp(&(b.0.jd_whole + b.0.tt_fraction))
});
let t0_cal = ordered[0].1.epoch;
let t0_ts = ordered[0].0;
let mut gcrs: Vec<GcrsSample> = Vec::with_capacity(samples.len());
for (ts, s) in &ordered {
let (x, y, z) =
itrs_to_gcrs_compute(s.x_m / M_PER_KM, s.y_m / M_PER_KM, s.z_m / M_PER_KM, ts)
.expect("valid frame transform");
let dt = dt_seconds(&t0_ts, ts);
gcrs.push(GcrsSample {
dt,
r_km: [x, y, z],
});
}
let dt_span = gcrs.iter().map(|s| s.dt).fold(f64::NEG_INFINITY, f64::max)
- gcrs.iter().map(|s| s.dt).fold(f64::INFINITY, f64::min);
if !dt_span.is_finite() || dt_span <= 0.0 {
return Err(ReducedOrbitError::InvalidWindow);
}
Ok((t0_cal, gcrs, dt_span))
}
fn fit_eccentric(
samples: &[EcefSample],
scale: TimeScale,
) -> Result<ReducedOrbit, ReducedOrbitError> {
let (t0_cal, gcrs, dt_span) = to_gcrs_samples(samples, scale)?;
let seed_c = seed_params(&gcrs)?;
let a_scale = seed_c[0];
let rate_scale = dt_span;
let seed_scaled = [
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, ];
let m = 3 * gcrs.len();
let residual = {
let gcrs_ref: Vec<(f64, [f64; 3])> = gcrs.iter().map(|s| (s.dt, s.r_km)).collect();
move |x: &DVector<f64>| -> DVector<f64> {
let xs = x.as_slice();
let p = unscale_params_ecc(xs, a_scale, rate_scale);
let mut r = Vec::with_capacity(m);
for (dt, obs) in &gcrs_ref {
let model = eval_gcrs_km_ecc(&p, *dt);
r.push(model[0] - obs[0]);
r.push(model[1] - obs[1]);
r.push(model[2] - obs[2]);
}
DVector::from_vec(r)
}
};
let x0 = DVector::from_row_slice(&seed_scaled);
let problem = LeastSquaresProblem::new(residual, x0);
let opts = SolveOptions {
gtol: REDUCED_ORBIT_SOLVER_TOL,
ftol: REDUCED_ORBIT_SOLVER_TOL,
xtol: REDUCED_ORBIT_SOLVER_TOL,
max_nfev: 400,
};
let report = solve_trf(&problem, &opts)?;
let converged = matches!(
report.status,
Status::GradientTolerance | Status::CostTolerance | Status::StepTolerance
);
if !converged {
return Err(ReducedOrbitError::FitDidNotConverge);
}
let p = unscale_params_ecc(report.x.as_slice(), a_scale, rate_scale);
let res = &report.residual;
let n_samp = gcrs.len();
let mut sumsq = 0.0;
let mut maxsq = 0.0_f64;
for k in 0..n_samp {
let dx = res[3 * k] * M_PER_KM;
let dy = res[3 * k + 1] * M_PER_KM;
let dz = res[3 * k + 2] * M_PER_KM;
let e2 = dx * dx + dy * dy + dz * dz;
sumsq += e2;
if e2 > maxsq {
maxsq = e2;
}
}
let rms_m = (sumsq / n_samp as f64).sqrt();
let max_m = maxsq.sqrt();
let h = p[4];
let k = p[5];
let e = (h * h + k * k).sqrt();
let arg_perigee_rad = if e < ECCENTRICITY_ZERO_EPS {
0.0
} else {
h.atan2(k)
};
let elements = Elements {
model: Model::EccentricSecular,
epoch: t0_cal,
a_m: p[0] * M_PER_KM,
e,
i_rad: p[1],
raan_rad: p[2],
raan_rate_rad_s: p[3],
raan_rate_j2_rad_s: raan_rate_j2(p[7], p[1], p[0]),
arg_lat_rad: p[6],
mean_motion_rad_s: p[7],
h,
k,
arg_perigee_rad,
};
Ok(ReducedOrbit {
elements,
stats: FitStats {
rms_m,
max_m,
n_samples: n_samp,
},
})
}
fn params_from_elements(e: &Elements) -> [f64; N_PARAMS] {
[
e.a_m / M_PER_KM,
e.i_rad,
e.raan_rad,
e.raan_rate_rad_s,
e.arg_lat_rad,
e.mean_motion_rad_s,
]
}
fn params_from_elements_ecc(e: &Elements) -> [f64; N_PARAMS_ECC] {
[
e.a_m / M_PER_KM,
e.i_rad,
e.raan_rad,
e.raan_rate_rad_s,
e.h,
e.k,
e.arg_lat_rad,
e.mean_motion_rad_s,
]
}
fn eval_position_km(elements: &Elements, dt: f64) -> [f64; 3] {
match elements.model {
Model::CircularSecular => eval_gcrs_km(¶ms_from_elements(elements), dt),
Model::EccentricSecular => eval_gcrs_km_ecc(¶ms_from_elements_ecc(elements), dt),
}
}
fn eval_velocity_km_s(elements: &Elements, dt: f64) -> [f64; 3] {
match elements.model {
Model::CircularSecular => eval_gcrs_velocity_km_s(¶ms_from_elements(elements), dt),
Model::EccentricSecular => {
eval_gcrs_velocity_km_s_ecc(¶ms_from_elements_ecc(elements), dt)
}
}
}
pub fn position(
elements: &Elements,
epoch: CalendarEpoch,
scale: TimeScale,
frame: Frame,
) -> Result<[f64; 3], ReducedOrbitError> {
validate_elements_for_evaluation(elements, scale)?;
validate_calendar_epoch(epoch, scale, "epoch")?;
let t0_ts = elements.epoch.time_scales(scale);
let ts = epoch.time_scales(scale);
let dt = dt_seconds(&t0_ts, &ts);
validate_finite(dt, "dt_s")?;
let r_gcrs_km = eval_position_km(elements, dt);
let r = match frame {
Frame::Gcrs => [
r_gcrs_km[0] * M_PER_KM,
r_gcrs_km[1] * M_PER_KM,
r_gcrs_km[2] * M_PER_KM,
],
Frame::Ecef => {
let mat = gcrs_to_itrs_matrix(&ts)
.map_err(|_| invalid_input("epoch", "invalid frame transform"))?;
let r = mat3_vec3_mul_unchecked(&mat, &r_gcrs_km);
[r[0] * M_PER_KM, r[1] * M_PER_KM, r[2] * M_PER_KM]
}
};
validate_vec3(r, "position_m")?;
Ok(r)
}
pub fn position_velocity(
elements: &Elements,
epoch: CalendarEpoch,
scale: TimeScale,
frame: Frame,
) -> Result<([f64; 3], [f64; 3]), ReducedOrbitError> {
validate_elements_for_evaluation(elements, scale)?;
validate_calendar_epoch(epoch, scale, "epoch")?;
let t0_ts = elements.epoch.time_scales(scale);
let ts = epoch.time_scales(scale);
let dt = dt_seconds(&t0_ts, &ts);
validate_finite(dt, "dt_s")?;
let r_gcrs_km = eval_position_km(elements, dt);
let v_gcrs_km_s = eval_velocity_km_s(elements, dt);
let (r, v) = match frame {
Frame::Gcrs => {
let r = [
r_gcrs_km[0] * M_PER_KM,
r_gcrs_km[1] * M_PER_KM,
r_gcrs_km[2] * M_PER_KM,
];
let v = [
v_gcrs_km_s[0] * M_PER_KM,
v_gcrs_km_s[1] * M_PER_KM,
v_gcrs_km_s[2] * M_PER_KM,
];
(r, v)
}
Frame::Ecef => {
let mat = gcrs_to_itrs_matrix(&ts)
.map_err(|_| invalid_input("epoch", "invalid frame transform"))?;
let r_itrs_km = mat3_vec3_mul_unchecked(&mat, &r_gcrs_km);
let v_rot_km_s = mat3_vec3_mul_unchecked(&mat, &v_gcrs_km_s);
let vx = v_rot_km_s[0] + OMEGA_E_DOT_RAD_S * r_itrs_km[1];
let vy = v_rot_km_s[1] - OMEGA_E_DOT_RAD_S * r_itrs_km[0];
let vz = v_rot_km_s[2];
let r = [
r_itrs_km[0] * M_PER_KM,
r_itrs_km[1] * M_PER_KM,
r_itrs_km[2] * M_PER_KM,
];
let v = [vx * M_PER_KM, vy * M_PER_KM, vz * M_PER_KM];
(r, v)
}
};
validate_vec3(r, "position_m")?;
validate_vec3(v, "velocity_m_s")?;
Ok((r, v))
}
fn validate_elements_for_evaluation(
elements: &Elements,
scale: TimeScale,
) -> Result<(), ReducedOrbitError> {
validate_calendar_epoch(elements.epoch, scale, "elements.epoch")?;
validate_positive(elements.a_m, "elements.a_m")?;
validate_finite(elements.e, "elements.e")?;
if !(0.0..1.0).contains(&elements.e) {
return Err(invalid_input("elements.e", "must be in [0, 1)"));
}
validate_finite(elements.i_rad, "elements.i_rad")?;
if !(0.0..=std::f64::consts::PI).contains(&elements.i_rad) {
return Err(invalid_input("elements.i_rad", "must be in [0, pi]"));
}
validate_finite(elements.raan_rad, "elements.raan_rad")?;
validate_finite(elements.raan_rate_rad_s, "elements.raan_rate_rad_s")?;
validate_finite(elements.raan_rate_j2_rad_s, "elements.raan_rate_j2_rad_s")?;
validate_finite(elements.arg_lat_rad, "elements.arg_lat_rad")?;
validate_positive(elements.mean_motion_rad_s, "elements.mean_motion_rad_s")?;
validate_finite(elements.h, "elements.h")?;
validate_finite(elements.k, "elements.k")?;
validate_finite(elements.arg_perigee_rad, "elements.arg_perigee_rad")?;
if elements.model == Model::EccentricSecular {
let derived_e = (elements.h * elements.h + elements.k * elements.k).sqrt();
validate_finite(derived_e, "elements.h_k")?;
if derived_e >= 1.0 {
return Err(invalid_input("elements.h_k", "eccentricity must be < 1"));
}
}
Ok(())
}
fn validate_calendar_epoch(
epoch: CalendarEpoch,
scale: TimeScale,
field: &'static str,
) -> Result<(), ReducedOrbitError> {
let second_policy = match scale {
TimeScale::Utc => validate::CivilSecondPolicy::UtcLike,
TimeScale::Glonasst
| TimeScale::Tai
| TimeScale::Tt
| TimeScale::Tdb
| TimeScale::Gpst
| TimeScale::Gst
| TimeScale::Bdt
| TimeScale::Qzsst => validate::CivilSecondPolicy::Continuous,
};
validate::civil_datetime_with_second_policy(
i64::from(epoch.year),
i64::from(epoch.month),
i64::from(epoch.day),
i64::from(epoch.hour),
i64::from(epoch.minute),
epoch.second,
second_policy,
)
.map(|_| ())
.map_err(|_| invalid_input(field, "invalid calendar epoch"))
}
fn validate_fit_epochs(samples: &[EcefSample], scale: TimeScale) -> Result<(), ReducedOrbitError> {
for sample in samples {
validate_calendar_epoch(sample.epoch, scale, "epoch")?;
validate_finite(sample.x_m, "sample.x_m")?;
validate_finite(sample.y_m, "sample.y_m")?;
validate_finite(sample.z_m, "sample.z_m")?;
}
Ok(())
}
fn validate_truth_sample(sample: &EcefSample, scale: TimeScale) -> Result<(), ReducedOrbitError> {
validate_calendar_epoch(sample.epoch, scale, "truth.epoch")?;
validate_finite(sample.x_m, "truth.x_m")?;
validate_finite(sample.y_m, "truth.y_m")?;
validate_finite(sample.z_m, "truth.z_m")
}
fn validate_vec3(value: [f64; 3], field: &'static str) -> Result<(), ReducedOrbitError> {
for component in value {
validate_finite(component, field)?;
}
Ok(())
}
fn validate_finite(value: f64, field: &'static str) -> Result<(), ReducedOrbitError> {
if value.is_finite() {
Ok(())
} else {
Err(invalid_input(field, "must be finite"))
}
}
fn validate_positive(value: f64, field: &'static str) -> Result<(), ReducedOrbitError> {
validate_finite(value, field)?;
if value > 0.0 {
Ok(())
} else {
Err(invalid_input(field, "must be positive"))
}
}
fn invalid_input(field: &'static str, reason: &'static str) -> ReducedOrbitError {
ReducedOrbitError::InvalidInput { field, reason }
}
pub fn drift(
elements: &Elements,
truth: &[EcefSample],
scale: TimeScale,
threshold_m: f64,
) -> Result<DriftReport, ReducedOrbitError> {
validate_elements_for_evaluation(elements, scale)?;
validate_finite(threshold_m, "threshold_m")?;
let mut per_epoch = Vec::with_capacity(truth.len());
let mut sumsq = 0.0;
let mut max_m = 0.0_f64;
let mut threshold_horizon = None;
let mut threshold_index = None;
for s in truth {
validate_truth_sample(s, scale)?;
let model = position(elements, s.epoch, scale, Frame::Ecef)?;
let dx = model[0] - s.x_m;
let dy = model[1] - s.y_m;
let dz = model[2] - s.z_m;
let err = (dx * dx + dy * dy + dz * dz).sqrt();
validate_finite(err, "drift.error_m")?;
sumsq += err * err;
validate_finite(sumsq, "drift.sumsq")?;
if err > max_m {
max_m = err;
}
if threshold_horizon.is_none() && err > threshold_m {
threshold_horizon = Some(s.epoch);
threshold_index = Some(per_epoch.len());
}
per_epoch.push(DriftEntry {
epoch: s.epoch,
error_m: err,
});
}
let rms_m = if per_epoch.is_empty() {
0.0
} else {
(sumsq / per_epoch.len() as f64).sqrt()
};
validate_finite(max_m, "drift.max_m")?;
validate_finite(rms_m, "drift.rms_m")?;
Ok(DriftReport {
per_epoch,
max_m,
rms_m,
threshold_horizon,
threshold_index,
})
}
pub fn fit_reduced_orbit_source(
source: ReducedOrbitSource<'_>,
options: ReducedOrbitSourceFitOptions,
) -> Result<ReducedOrbitSourceFit, ReducedOrbitSourceError> {
let sampled = sample_reduced_orbit_source(source, options.sampling)?;
let orbit = fit_with_model(&sampled.samples, sampled.scale, options.model)
.map_err(ReducedOrbitSourceError::Reduced)?;
Ok(ReducedOrbitSourceFit {
orbit,
requested_samples: sampled.requested,
})
}
pub fn drift_reduced_orbit_source(
elements: &Elements,
source: ReducedOrbitSource<'_>,
options: ReducedOrbitSourceDriftOptions,
) -> Result<ReducedOrbitSourceDrift, ReducedOrbitSourceError> {
let sampled = sample_reduced_orbit_source(source, options.sampling)?;
if sampled.samples.is_empty() {
return Err(ReducedOrbitSourceError::TooFewSamples {
got: 0,
required: 1,
});
}
let report = drift(
elements,
&sampled.samples,
sampled.scale,
options.threshold_m,
)
.map_err(ReducedOrbitSourceError::Reduced)?;
Ok(ReducedOrbitSourceDrift {
report,
requested_samples: sampled.requested,
})
}
pub fn fit_piecewise_reduced_orbit_source(
source: ReducedOrbitSource<'_>,
options: PiecewiseOrbitSourceFitOptions,
) -> Result<PiecewiseOrbitSourceFit, ReducedOrbitSourceError> {
let sampled = sample_reduced_orbit_source(source, options.sampling)?;
let segment_s = rounded_segment_s(options.segment_s)?;
let orbit = fit_piecewise(
&sampled.samples,
sampled.scale,
options.model,
options.sampling.t0,
options.sampling.t1,
segment_s,
)
.map_err(ReducedOrbitSourceError::Piecewise)?;
Ok(PiecewiseOrbitSourceFit {
orbit,
requested_samples: sampled.requested,
})
}
pub fn drift_piecewise_reduced_orbit_source(
piecewise: &PiecewiseOrbit,
source: ReducedOrbitSource<'_>,
options: ReducedOrbitSourceDriftOptions,
) -> Result<ReducedOrbitSourceDrift, ReducedOrbitSourceError> {
let sampled = sample_reduced_orbit_source(source, options.sampling)?;
if sampled.samples.is_empty() {
return Err(ReducedOrbitSourceError::TooFewSamples {
got: 0,
required: 1,
});
}
let report = piecewise_drift(
piecewise,
&sampled.samples,
sampled.scale,
options.threshold_m,
)
.map_err(ReducedOrbitSourceError::Piecewise)?;
Ok(ReducedOrbitSourceDrift {
report,
requested_samples: sampled.requested,
})
}
#[derive(Debug)]
struct SourceSamples {
samples: Vec<EcefSample>,
requested: usize,
scale: TimeScale,
}
fn sample_reduced_orbit_source(
source: ReducedOrbitSource<'_>,
sampling: ReducedOrbitSourceSampling,
) -> Result<SourceSamples, ReducedOrbitSourceError> {
let scale = match source {
ReducedOrbitSource::Sp3 { product, .. } => product.header.time_scale,
ReducedOrbitSource::Sgp4 { .. } => TimeScale::Utc,
};
let steps = source_steps(sampling, scale)?;
let samples = match source {
ReducedOrbitSource::Sp3 { product, satellite } => steps
.iter()
.filter_map(|epoch| sample_sp3_epoch(product, satellite, *epoch))
.collect(),
ReducedOrbitSource::Sgp4 { satellite } => steps
.iter()
.filter_map(|epoch| sample_sgp4_epoch(satellite, *epoch))
.collect(),
};
Ok(SourceSamples {
samples,
requested: steps.len(),
scale,
})
}
fn source_steps(
sampling: ReducedOrbitSourceSampling,
scale: TimeScale,
) -> Result<Vec<CalendarEpoch>, ReducedOrbitSourceError> {
validate_calendar_epoch(sampling.t0, scale, "window.start")
.map_err(ReducedOrbitSourceError::Reduced)?;
validate_calendar_epoch(sampling.t1, scale, "window.end")
.map_err(ReducedOrbitSourceError::Reduced)?;
if !sampling.cadence_s.is_finite() || sampling.cadence_s <= 0.0 {
return Err(ReducedOrbitSourceError::InvalidCadence);
}
let span_s = calendar_seconds(sampling.t1) - calendar_seconds(sampling.t0);
if !span_s.is_finite() || span_s <= 0.0 {
return Err(ReducedOrbitSourceError::InvalidWindow);
}
let count = (span_s / sampling.cadence_s).trunc();
if !count.is_finite() || count < 0.0 {
return Err(ReducedOrbitSourceError::InvalidWindow);
}
let start_s = calendar_seconds(sampling.t0);
Ok((0..=count as usize)
.map(|k| calendar_from_seconds(start_s + (k as f64 * sampling.cadence_s).round()))
.collect())
}
fn sample_sp3_epoch(
product: &Sp3,
satellite: GnssSatelliteId,
epoch: CalendarEpoch,
) -> Option<EcefSample> {
let instant = instant_from_calendar(epoch, product.header.time_scale).ok()?;
let state = product.position(satellite, instant).ok()?;
Some(EcefSample::new(
epoch,
state.position.x_m,
state.position.y_m,
state.position.z_m,
))
}
fn sample_sgp4_epoch(satellite: &Satellite, epoch: CalendarEpoch) -> Option<EcefSample> {
let prediction = satellite
.propagate_jd(julian_date_from_calendar(epoch))
.ok()?;
let ts = epoch.time_scales(TimeScale::Utc);
let (gcrs, _) = teme_to_gcrs_compute(
&TemeStateKm {
position_km: prediction.position,
velocity_km_s: prediction.velocity,
},
&ts,
false,
)
.ok()?;
let (x_km, y_km, z_km) = gcrs_to_itrs_compute(gcrs.0, gcrs.1, gcrs.2, &ts, false).ok()?;
Some(EcefSample::new(
epoch,
x_km * M_PER_KM,
y_km * M_PER_KM,
z_km * M_PER_KM,
))
}
fn instant_from_calendar(
epoch: CalendarEpoch,
scale: TimeScale,
) -> Result<Instant, ReducedOrbitSourceError> {
let (jd_whole, fraction) = split_julian_date(
epoch.year,
epoch.month,
epoch.day,
epoch.hour,
epoch.minute,
epoch.second,
);
let split = JulianDateSplit::new(jd_whole, fraction)
.map_err(|_| ReducedOrbitSourceError::InvalidWindow)?;
Ok(Instant::from_julian_date(scale, split))
}
fn julian_date_from_calendar(epoch: CalendarEpoch) -> JulianDate {
let (jd_whole, fraction) = split_julian_date(
epoch.year,
epoch.month,
epoch.day,
epoch.hour,
epoch.minute,
epoch.second,
);
JulianDate(jd_whole, fraction)
}
fn rounded_segment_s(segment_s: f64) -> Result<i64, ReducedOrbitSourceError> {
if !segment_s.is_finite() || segment_s <= 0.0 {
return Err(ReducedOrbitSourceError::InvalidSegment);
}
let rounded = segment_s.round();
if rounded < 1.0 || rounded > i64::MAX as f64 {
return Err(ReducedOrbitSourceError::InvalidSegment);
}
Ok(rounded as i64)
}
fn calendar_seconds(t: CalendarEpoch) -> f64 {
julian_day_number(t.year, t.month, t.day) as f64 * SECONDS_PER_DAY
+ (t.hour as f64) * SECONDS_PER_HOUR
+ (t.minute as f64) * SECONDS_PER_MINUTE
+ t.second
}
fn calendar_from_seconds(total_s: f64) -> CalendarEpoch {
let mut jdn = (total_s / SECONDS_PER_DAY).floor() as i64;
let mut sod = total_s - jdn as f64 * SECONDS_PER_DAY;
if sod < 0.0 {
jdn -= 1;
sod += SECONDS_PER_DAY;
}
if sod >= SECONDS_PER_DAY {
jdn += 1;
sod -= SECONDS_PER_DAY;
}
let (year, month, day) = civil_from_julian_day_number(jdn);
let hour = (sod / SECONDS_PER_HOUR).floor() as i32;
let rem = sod - hour as f64 * SECONDS_PER_HOUR;
let minute = (rem / SECONDS_PER_MINUTE).floor() as i32;
let second = rem - minute as f64 * SECONDS_PER_MINUTE;
CalendarEpoch::new(year as i32, month as i32, day as i32, hour, minute, second)
}
fn calendar_add_seconds(t: CalendarEpoch, seconds: i64) -> CalendarEpoch {
calendar_from_seconds(calendar_seconds(t) + seconds as f64)
}
fn segment_bounds(
t0: CalendarEpoch,
t1: CalendarEpoch,
segment_s: i64,
) -> Result<Vec<(CalendarEpoch, CalendarEpoch)>, PiecewiseOrbitError> {
if segment_s < 1 {
return Err(PiecewiseOrbitError::InvalidSegment);
}
if calendar_seconds(t1) <= calendar_seconds(t0) {
return Err(PiecewiseOrbitError::Reduced(
ReducedOrbitError::InvalidWindow,
));
}
let mut bounds = Vec::new();
let mut seg_t0 = t0;
let end_s = calendar_seconds(t1);
while calendar_seconds(seg_t0) < end_s {
let mut seg_t1 = calendar_add_seconds(seg_t0, segment_s);
if calendar_seconds(seg_t1) > end_s {
seg_t1 = t1;
}
bounds.push((seg_t0, seg_t1));
seg_t0 = seg_t1;
}
Ok(bounds)
}
fn is_too_few_or_invalid_window(e: &ReducedOrbitError) -> bool {
matches!(
e,
ReducedOrbitError::TooFewSamples { .. } | ReducedOrbitError::InvalidWindow
)
}
fn sample_in_bounds(sample: CalendarEpoch, t0: CalendarEpoch, t1: CalendarEpoch) -> bool {
let s = calendar_seconds(sample);
s >= calendar_seconds(t0) && s <= calendar_seconds(t1)
}
pub fn fit_piecewise(
samples: &[EcefSample],
scale: TimeScale,
model: Model,
t0: CalendarEpoch,
t1: CalendarEpoch,
segment_s: i64,
) -> Result<PiecewiseOrbit, PiecewiseOrbitError> {
let bounds = segment_bounds(t0, t1, segment_s)?;
let last_index = bounds.len().saturating_sub(1);
let mut segments = Vec::new();
for (index, (seg_t0, seg_t1)) in bounds.into_iter().enumerate() {
let subset: Vec<EcefSample> = samples
.iter()
.copied()
.filter(|s| sample_in_bounds(s.epoch, seg_t0, seg_t1))
.collect();
match fit_with_model(&subset, scale, model) {
Ok(orbit) => segments.push(PiecewiseSegment {
t0: seg_t0,
t1: seg_t1,
orbit,
}),
Err(e) if index == last_index && is_too_few_or_invalid_window(&e) => {}
Err(e) => return Err(PiecewiseOrbitError::Reduced(e)),
}
}
let Some(last) = segments.last() else {
return Err(PiecewiseOrbitError::TooFewSamples {
got: 0,
required: MIN_SAMPLES,
});
};
Ok(PiecewiseOrbit {
model,
t0,
t1: last.t1,
segment_s,
segments,
})
}
pub fn select_piecewise_segment(
piecewise: &PiecewiseOrbit,
epoch: CalendarEpoch,
) -> Result<&PiecewiseSegment, PiecewiseOrbitError> {
validate_piecewise_segments(piecewise)?;
let e = calendar_seconds(epoch);
if e < calendar_seconds(piecewise.t0) || e > calendar_seconds(piecewise.t1) {
return Err(PiecewiseOrbitError::OutOfRange);
}
let Some((last, rest)) = piecewise.segments.split_last() else {
return Err(PiecewiseOrbitError::OutOfRange);
};
for seg in rest {
if e >= calendar_seconds(seg.t0) && e < calendar_seconds(seg.t1) {
return Ok(seg);
}
}
if e >= calendar_seconds(last.t0) && e <= calendar_seconds(last.t1) {
Ok(last)
} else {
Err(PiecewiseOrbitError::OutOfRange)
}
}
fn validate_piecewise_segments(piecewise: &PiecewiseOrbit) -> Result<(), PiecewiseOrbitError> {
if piecewise.segment_s < 1 {
return Err(PiecewiseOrbitError::InvalidSegment);
}
validate::require_strictly_increasing(
piecewise
.segments
.iter()
.map(|segment| calendar_seconds(segment.t0)),
"piecewise.segments.t0",
)
.map_err(map_piecewise_order_error)?;
for segment in &piecewise.segments {
validate::require_strictly_increasing(
[calendar_seconds(segment.t0), calendar_seconds(segment.t1)],
"piecewise.segment bounds",
)
.map_err(map_piecewise_order_error)?;
}
Ok(())
}
fn map_piecewise_order_error(error: validate::FieldError) -> PiecewiseOrbitError {
let reason = match error {
validate::FieldError::NonFinite { .. } => "must be finite",
validate::FieldError::OutOfRange { .. } => "must be strictly increasing",
_ => error.reason(),
};
PiecewiseOrbitError::Reduced(invalid_input(error.field(), reason))
}
pub fn piecewise_position(
piecewise: &PiecewiseOrbit,
epoch: CalendarEpoch,
scale: TimeScale,
frame: Frame,
) -> Result<[f64; 3], PiecewiseOrbitError> {
validate_calendar_epoch(epoch, scale, "epoch").map_err(PiecewiseOrbitError::Reduced)?;
let seg = select_piecewise_segment(piecewise, epoch)?;
position(&seg.orbit.elements, epoch, scale, frame).map_err(PiecewiseOrbitError::Reduced)
}
pub fn piecewise_position_velocity(
piecewise: &PiecewiseOrbit,
epoch: CalendarEpoch,
scale: TimeScale,
frame: Frame,
) -> Result<([f64; 3], [f64; 3]), PiecewiseOrbitError> {
validate_calendar_epoch(epoch, scale, "epoch").map_err(PiecewiseOrbitError::Reduced)?;
let seg = select_piecewise_segment(piecewise, epoch)?;
position_velocity(&seg.orbit.elements, epoch, scale, frame)
.map_err(PiecewiseOrbitError::Reduced)
}
pub fn piecewise_drift(
piecewise: &PiecewiseOrbit,
truth: &[EcefSample],
scale: TimeScale,
threshold_m: f64,
) -> Result<DriftReport, PiecewiseOrbitError> {
validate_finite(threshold_m, "threshold_m").map_err(PiecewiseOrbitError::Reduced)?;
if truth.is_empty() {
return Ok(DriftReport {
per_epoch: Vec::new(),
max_m: 0.0,
rms_m: 0.0,
threshold_horizon: None,
threshold_index: None,
});
}
let mut per_epoch = Vec::with_capacity(truth.len());
let mut sumsq = 0.0;
let mut max_m = 0.0_f64;
let mut threshold_horizon = None;
let mut threshold_index = None;
for s in truth {
validate_truth_sample(s, scale).map_err(PiecewiseOrbitError::Reduced)?;
let Ok(model) = piecewise_position(piecewise, s.epoch, scale, Frame::Ecef) else {
continue;
};
let dx = model[0] - s.x_m;
let dy = model[1] - s.y_m;
let dz = model[2] - s.z_m;
let err = (dx * dx + dy * dy + dz * dz).sqrt();
sumsq += err * err;
if err > max_m {
max_m = err;
}
if threshold_horizon.is_none() && err > threshold_m {
threshold_horizon = Some(s.epoch);
threshold_index = Some(per_epoch.len());
}
per_epoch.push(DriftEntry {
epoch: s.epoch,
error_m: err,
});
}
if per_epoch.is_empty() {
return Err(PiecewiseOrbitError::TooFewSamples {
got: 0,
required: 1,
});
}
let rms_m = (sumsq / per_epoch.len() as f64).sqrt();
Ok(DriftReport {
per_epoch,
max_m,
rms_m,
threshold_horizon,
threshold_index,
})
}
#[cfg(all(test, sidereon_repo_tests))]
mod tests;