use super::solver::{solve_sun_earth_lagrange_dyn_with_config, SolverConfig, SolverError};
use super::{evaluate_records, SunEarthLagrangePoint};
use crate::coordinates::cartesian::Position;
use crate::coordinates::centers::Barycentric;
use crate::coordinates::frames::EclipticMeanJ2000;
use crate::ephemeris::DynEphemeris;
use crate::qtty::{AstronomicalUnit, Kilometer, Meters, Second, Seconds};
use crate::time::JulianDate;
use std::fmt;
const SECONDS_PER_DAY: f64 = crate::qtty::time::SECONDS_PER_DAY;
const J2000_JD: f64 = tempoch::J2000_JD_TT_DAY.value();
const DEFAULT_FROM_JD: f64 = 2_415_020.5;
const DEFAULT_TO_JD: f64 = 2_488_070.5;
const DEFAULT_BLOCK_SECONDS: f64 = 32.0 * SECONDS_PER_DAY;
const DEFAULT_VALIDATION_SECONDS: f64 = 6.0 * 3_600.0;
const DEFAULT_TOLERANCE_METERS: f64 = 100.0;
#[derive(Debug, Copy, Clone, PartialEq)]
pub struct FitConfig {
pub from: JulianDate,
pub to: JulianDate,
pub block: Second,
pub validation_step: Second,
pub tolerance: Meters,
pub solver: SolverConfig,
}
impl Default for FitConfig {
fn default() -> Self {
Self {
from: crate::time::try_jd_f64(DEFAULT_FROM_JD).expect("valid default start JD"),
to: crate::time::try_jd_f64(DEFAULT_TO_JD).expect("valid default end JD"),
block: Seconds::new(DEFAULT_BLOCK_SECONDS),
validation_step: Seconds::new(DEFAULT_VALIDATION_SECONDS),
tolerance: Meters::new(DEFAULT_TOLERANCE_METERS),
solver: SolverConfig::default(),
}
}
}
#[derive(Debug, Copy, Clone, PartialEq)]
pub struct FitStats {
pub max_abs_error: Meters,
pub rms_error: Meters,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FittedPoint {
pub point: SunEarthLagrangePoint,
pub ncoeff: usize,
pub records: Vec<f64>,
pub stats: FitStats,
}
#[derive(Debug, Clone, PartialEq)]
pub enum FitError {
InvalidConfig,
InvalidEpoch,
Solver(SolverError),
ToleranceNotMet {
max_abs_error: Meters,
},
}
impl fmt::Display for FitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidConfig => write!(f, "invalid Lagrange fit configuration"),
Self::InvalidEpoch => write!(f, "invalid Julian Date while fitting Lagrange records"),
Self::Solver(err) => write!(f, "solver failed while fitting Lagrange records: {err}"),
Self::ToleranceNotMet { max_abs_error } => write!(
f,
"Lagrange Chebyshev fit did not meet tolerance; best max error={} m",
max_abs_error.value()
),
}
}
}
impl std::error::Error for FitError {}
impl From<SolverError> for FitError {
fn from(value: SolverError) -> Self {
Self::Solver(value)
}
}
pub fn fit_sun_earth_lagrange(
ephemeris: &dyn DynEphemeris,
point: SunEarthLagrangePoint,
config: FitConfig,
) -> Result<FittedPoint, FitError> {
let mut best = None;
for ncoeff in [16_usize, 24, 32, 40, 48] {
let fitted = match ncoeff {
16 => fit_with_order::<16>(ephemeris, point, config)?,
24 => fit_with_order::<24>(ephemeris, point, config)?,
32 => fit_with_order::<32>(ephemeris, point, config)?,
40 => fit_with_order::<40>(ephemeris, point, config)?,
48 => fit_with_order::<48>(ephemeris, point, config)?,
_ => unreachable!(),
};
let max_error = fitted.stats.max_abs_error.value();
best = Some(fitted.clone());
if max_error <= config.tolerance.value() {
return Ok(fitted);
}
}
let max_abs_error = best.map_or(Meters::new(f64::INFINITY), |fitted| {
fitted.stats.max_abs_error
});
Err(FitError::ToleranceNotMet { max_abs_error })
}
pub fn evaluate_fitted(
fitted: &FittedPoint,
jd: JulianDate,
) -> Result<
Position<Barycentric, EclipticMeanJ2000, AstronomicalUnit>,
crate::ephemeris::EphemerisError,
> {
evaluate_records(&fitted.records, fitted.ncoeff, jd)
}
fn fit_with_order<const N: usize>(
ephemeris: &dyn DynEphemeris,
point: SunEarthLagrangePoint,
config: FitConfig,
) -> Result<FittedPoint, FitError> {
validate_config(config)?;
let from_s = seconds_since_j2000(config.from);
let to_s = seconds_since_j2000(config.to);
let block_s = config.block.value();
let mut records = Vec::new();
let mut start = from_s;
while start < to_s {
let end = (start + block_s).min(to_s);
let mid = 0.5 * (start + end);
let radius = 0.5 * (end - start);
let nodes = cheby::nodes::<N>();
let mut xs = [0.0_f64; N];
let mut ys = [0.0_f64; N];
let mut zs = [0.0_f64; N];
for (idx, node) in nodes.iter().enumerate() {
let sample_s = mid + radius * node;
let jd = jd_from_seconds(sample_s)?;
let solution = solve_sun_earth_lagrange_dyn_with_config(
ephemeris,
point,
jd,
config.solver,
None,
)?;
xs[idx] = solution.position.x().value();
ys[idx] = solution.position.y().value();
zs[idx] = solution.position.z().value();
}
let cx = cheby::fit_coeffs(&xs);
let cy = cheby::fit_coeffs(&ys);
let cz = cheby::fit_coeffs(&zs);
records.push(mid);
records.push(radius);
records.extend_from_slice(&cx);
records.extend_from_slice(&cy);
records.extend_from_slice(&cz);
start = end;
}
let stats = validate_fit(ephemeris, point, config, N, &records)?;
Ok(FittedPoint {
point,
ncoeff: N,
records,
stats,
})
}
fn validate_fit(
ephemeris: &dyn DynEphemeris,
point: SunEarthLagrangePoint,
config: FitConfig,
ncoeff: usize,
records: &[f64],
) -> Result<FitStats, FitError> {
let mut sample_s = seconds_since_j2000(config.from);
let end_s = seconds_since_j2000(config.to);
let step_s = config.validation_step.value();
let mut max_error = 0.0_f64;
let mut sum_sq = 0.0_f64;
let mut count = 0_usize;
while sample_s <= end_s + 1.0e-9 {
let jd = jd_from_seconds(sample_s)?;
let truth =
solve_sun_earth_lagrange_dyn_with_config(ephemeris, point, jd, config.solver, None)?;
let fit = evaluate_records(records, ncoeff, jd).map_err(|_| FitError::InvalidConfig)?;
let fit_km = fit.to_unit::<Kilometer>();
let err_km = distance_km(truth.position, fit_km);
let err_m = err_km * 1_000.0;
max_error = max_error.max(err_m);
sum_sq += err_m * err_m;
count += 1;
sample_s += step_s;
}
let rms = if count == 0 {
0.0
} else {
(sum_sq / count as f64).sqrt()
};
Ok(FitStats {
max_abs_error: Meters::new(max_error),
rms_error: Meters::new(rms),
})
}
fn validate_config(config: FitConfig) -> Result<(), FitError> {
let from = seconds_since_j2000(config.from);
let to = seconds_since_j2000(config.to);
let block = config.block.value();
let step = config.validation_step.value();
if from.is_finite()
&& to.is_finite()
&& to > from
&& block.is_finite()
&& block > 0.0
&& step.is_finite()
&& step > 0.0
{
Ok(())
} else {
Err(FitError::InvalidConfig)
}
}
fn seconds_since_j2000(jd: JulianDate) -> f64 {
(jd.raw().value() - J2000_JD) * SECONDS_PER_DAY
}
fn jd_from_seconds(seconds: f64) -> Result<JulianDate, FitError> {
crate::time::try_jd_f64(J2000_JD + seconds / SECONDS_PER_DAY)
.map_err(|_| FitError::InvalidEpoch)
}
fn distance_km(
lhs: Position<Barycentric, EclipticMeanJ2000, Kilometer>,
rhs: Position<Barycentric, EclipticMeanJ2000, Kilometer>,
) -> f64 {
let dx = lhs.x().value() - rhs.x().value();
let dy = lhs.y().value() - rhs.y().value();
let dz = lhs.z().value() - rhs.z().value();
(dx * dx + dy * dy + dz * dz).sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ephemeris::Vsop87Ephemeris;
#[test]
fn tiny_fixture_fits_and_evaluates() {
let config = FitConfig {
from: crate::time::try_jd_f64(J2000_JD).expect("valid JD"),
to: crate::time::try_jd_f64(J2000_JD + 0.25).expect("valid JD"),
block: Seconds::new(0.125 * SECONDS_PER_DAY),
validation_step: Seconds::new(0.125 * SECONDS_PER_DAY),
tolerance: Meters::new(5_000_000.0),
solver: SolverConfig::default(),
};
let eph = Vsop87Ephemeris;
let fitted = fit_sun_earth_lagrange(&eph, SunEarthLagrangePoint::L1, config)
.expect("fixture should fit");
assert!(!fitted.records.is_empty());
let evaluated = evaluate_fitted(&fitted, config.from).expect("fit should evaluate");
assert!(evaluated.x().value().is_finite());
}
}