use kshana::orbit_determination::{determine_orbit_batch, determine_orbit_sequential, Station};
const REF: &str = include_str!(
"fixtures/batch_sequential_orbit_determination/batch_sequential_orbit_determination_reference.txt"
);
const BATCH_POS_TOL: f64 = 0.05; const BATCH_VEL_TOL: f64 = 5.0e-5; const BATCH_POS_TOL_NOISY: f64 = 0.05; const BATCH_VEL_TOL_NOISY: f64 = 5.0e-5; const RMS_REL_TOL: f64 = 1.0e-3; const SEQ_POS_TOL: f64 = 0.05; const SEQ_POS_TOL_NOISY: f64 = 3.0;
fn parse_csv(s: &str) -> Vec<f64> {
s.trim()
.split(',')
.map(|x| {
x.trim()
.parse::<f64>()
.unwrap_or_else(|_| panic!("bad number '{x}' in '{s}'"))
})
.collect()
}
fn pos_err(a: &[f64], b: &[f64]) -> f64 {
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
}
fn vel_err(a: &[f64], b: &[f64]) -> f64 {
((a[3] - b[3]).powi(2) + (a[4] - b[4]).powi(2) + (a[5] - b[5]).powi(2)).sqrt()
}
#[derive(Default)]
struct Scenario {
name: String,
dt: f64,
n_batch: usize,
n_seq: usize,
sigma: f64,
n_stations: usize,
truth: Vec<f64>,
stations: Vec<Station>,
ranges_batch: Vec<Vec<f64>>, ranges_seq: Vec<Vec<f64>>, orekit_batch: Vec<f64>,
orekit_batch_rms: f64,
orekit_seq_final: Vec<f64>,
}
fn parse_scenarios() -> Vec<Scenario> {
let mut out = Vec::new();
let mut cur: Option<Scenario> = None;
for line in REF.lines() {
let line = line.trim_end();
if line.starts_with('#') || line.is_empty() {
continue;
}
if let Some(rest) = line.strip_prefix("SCEN ") {
if let Some(s) = cur.take() {
out.push(s);
}
let p: Vec<&str> = rest.split('|').map(|x| x.trim()).collect();
let mut s = Scenario {
name: p[0].to_string(),
dt: p[1].parse().unwrap(),
n_batch: p[2].parse().unwrap(),
n_seq: p[3].parse().unwrap(),
sigma: p[4].parse().unwrap(),
n_stations: p[5].parse().unwrap(),
..Default::default()
};
s.ranges_batch = vec![Vec::new(); s.n_batch];
s.ranges_seq = vec![Vec::new(); s.n_seq];
cur = Some(s);
} else if let Some(rest) = line.strip_prefix("TRUTH ") {
cur.as_mut().unwrap().truth = parse_csv(rest);
} else if let Some(rest) = line.strip_prefix("STATION ") {
let (_idx, coords) = rest.split_once('|').unwrap();
let c = parse_csv(coords);
cur.as_mut().unwrap().stations.push(Station {
pos: [c[0], c[1], c[2]],
});
} else if let Some(rest) = line.strip_prefix("RANGE_BATCH ") {
let (k, vals) = rest.split_once('|').unwrap();
let k: usize = k.trim().parse().unwrap();
cur.as_mut().unwrap().ranges_batch[k - 1] = parse_csv(vals);
} else if let Some(rest) = line.strip_prefix("RANGE_SEQ ") {
let (k, vals) = rest.split_once('|').unwrap();
let k: usize = k.trim().parse().unwrap();
cur.as_mut().unwrap().ranges_seq[k - 1] = parse_csv(vals);
} else if let Some(rest) = line.strip_prefix("OREKIT_BATCH ") {
let (state, rms) = rest.split_once('|').unwrap();
let s = cur.as_mut().unwrap();
s.orekit_batch = parse_csv(state);
s.orekit_batch_rms = rms.trim().parse().unwrap();
} else if let Some(rest) = line.strip_prefix("OREKIT_SEQ_EPOCH ") {
let _ = parse_csv(rest); } else if let Some(rest) = line.strip_prefix("OREKIT_SEQ_FINAL ") {
cur.as_mut().unwrap().orekit_seq_final = parse_csv(rest);
}
}
if let Some(s) = cur.take() {
out.push(s);
}
out
}
fn diag(d: &[f64]) -> Vec<Vec<f64>> {
let n = d.len();
let mut m = vec![vec![0.0; n]; n];
for (i, &v) in d.iter().enumerate() {
m[i][i] = v;
}
m
}
#[test]
fn batch_and_sequential_od_match_orekit() {
let scenarios = parse_scenarios();
assert!(
scenarios.len() >= 5,
"expected >= 5 OD scenarios, got {}",
scenarios.len()
);
let mut worst_batch_pos = 0.0_f64;
let mut worst_batch_vel = 0.0_f64;
let mut worst_seq_pos = 0.0_f64;
let mut worst_rms_rel = 0.0_f64;
let mut n_noiseless = 0usize;
for s in &scenarios {
assert_eq!(s.stations.len(), s.n_stations, "{}: station count", s.name);
assert_eq!(s.truth.len(), 6, "{}: truth state", s.name);
let noisy = s.sigma > 0.0;
let mut z = Vec::with_capacity(s.n_batch * s.n_stations);
for ep in &s.ranges_batch {
assert_eq!(ep.len(), s.n_stations, "{}: batch range row width", s.name);
z.extend_from_slice(ep);
}
let w = if noisy {
1.0 / (s.sigma * s.sigma)
} else {
1.0
};
let weights = vec![w; z.len()];
let guess = [
s.truth[0] + 1000.0,
s.truth[1] - 800.0,
s.truth[2] + 600.0,
s.truth[3] + 5.0,
s.truth[4] - 4.0,
s.truth[5] + 3.0,
];
let (max_iter, tol) = if noisy { (80, 1e-3) } else { (60, 1e-9) };
let sol = determine_orbit_batch(
&z,
&weights,
&guess,
&s.stations,
s.dt,
s.n_batch,
max_iter,
tol,
)
.unwrap_or_else(|| panic!("{}: kshana batch OD returned None", s.name));
let bp = pos_err(&sol.x, &s.orekit_batch);
let bv = vel_err(&sol.x, &s.orekit_batch);
let (pt, vt) = if noisy {
(BATCH_POS_TOL_NOISY, BATCH_VEL_TOL_NOISY)
} else {
(BATCH_POS_TOL, BATCH_VEL_TOL)
};
worst_batch_pos = worst_batch_pos.max(bp);
worst_batch_vel = worst_batch_vel.max(bv);
assert!(
bp <= pt,
"{}: batch position |Δ| {bp:.4e} m vs Orekit > {pt} m \
(kshana {:?} vs Orekit {:?})",
s.name,
&sol.x[..3],
&s.orekit_batch[..3]
);
assert!(
bv <= vt,
"{}: batch velocity |Δ| {bv:.4e} m/s vs Orekit > {vt} m/s",
s.name
);
if noisy {
let rel = (sol.rms_residual - s.orekit_batch_rms).abs() / s.orekit_batch_rms.max(1e-9);
worst_rms_rel = worst_rms_rel.max(rel);
assert!(
rel <= RMS_REL_TOL,
"{}: post-fit RMS {} m vs Orekit {} m (rel {rel:.3} > {RMS_REL_TOL})",
s.name,
sol.rms_residual,
s.orekit_batch_rms
);
} else {
n_noiseless += 1;
assert!(
sol.rms_residual < 1e-2,
"{}: noiseless post-fit RMS should be ~0, got {} m",
s.name,
sol.rms_residual
);
}
let p0 = diag(&[1.0e6, 1.0e6, 1.0e6, 1.0e2, 1.0e2, 1.0e2]);
let q = diag(&[1.0e-3, 1.0e-3, 1.0e-3, 1.0e-6, 1.0e-6, 1.0e-6]);
let ranges_per_epoch: Vec<Vec<f64>> = s.ranges_seq.clone();
let seq_sigma = if noisy { s.sigma } else { 1.0 };
let ukf = determine_orbit_sequential(
guess.to_vec(),
p0,
&q,
&s.stations,
&ranges_per_epoch,
seq_sigma,
s.dt,
);
let sp = pos_err(&ukf.x, &s.orekit_seq_final);
let spt = if noisy {
SEQ_POS_TOL_NOISY
} else {
SEQ_POS_TOL
};
worst_seq_pos = worst_seq_pos.max(sp);
assert!(
sp <= spt,
"{}: sequential final-epoch position |Δ| {sp:.4e} m vs Orekit > {spt} m \
(kshana {:?} vs Orekit {:?})",
s.name,
&ukf.x[..3],
&s.orekit_seq_final[..3]
);
}
assert!(
n_noiseless >= 5,
"expected >= 5 noiseless scenarios, got {n_noiseless}"
);
eprintln!(
"batch_sequential_orbit_determination_reference: {} scenarios vs Orekit 12.2 \
(BatchLSEstimator LM + KalmanEstimator). worst batch |Δr| = {:.3e} m, |Δv| = {:.3e} m/s; \
worst sequential |Δr| = {:.3e} m; worst post-fit RMS rel = {:.3e}.",
scenarios.len(),
worst_batch_pos,
worst_batch_vel,
worst_seq_pos,
worst_rms_rel
);
}