use crate::fim::{design_metrics, information_matrix, sym_eig};
use crate::intersat_range::{range_rate_row, range_row, PlanarState};
pub type Mat = Vec<Vec<f64>>;
pub const N_PLANAR: usize = 4;
const PLANAR_IDX: [usize; N_PLANAR] = [0, 1, 3, 4];
pub fn planar_state_stm(
s0: &PlanarState,
mu: f64,
t: f64,
steps: usize,
) -> (PlanarState, [[f64; N_PLANAR]; N_PLANAR]) {
let embed = crate::cr3bp::Cr3bpState {
r: [s0[0], s0[1], 0.0],
v: [s0[2], s0[3], 0.0],
};
let (st, phi6) = crate::cr3bp::propagate_state_stm(&embed, mu, t, steps);
let state = [st.r[0], st.r[1], st.v[0], st.v[1]];
let mut phi = [[0.0; N_PLANAR]; N_PLANAR];
for (i, &ri) in PLANAR_IDX.iter().enumerate() {
for (j, &cj) in PLANAR_IDX.iter().enumerate() {
phi[i][j] = phi6[ri][cj];
}
}
(state, phi)
}
pub fn planar_propagate(s0: &PlanarState, mu: f64, t: f64, steps: usize) -> PlanarState {
let embed = crate::cr3bp::Cr3bpState {
r: [s0[0], s0[1], 0.0],
v: [s0[2], s0[3], 0.0],
};
let st = crate::cr3bp::propagate_cr3bp(embed, mu, t, steps);
[st.r[0], st.r[1], st.v[0], st.v[1]]
}
#[derive(Clone, Debug)]
pub struct ObsEpoch {
pub h: Mat,
pub phi: Mat,
pub dt: f64,
}
#[allow(clippy::needless_range_loop)]
fn row_times_matrix(h: &[f64], phi: &Mat) -> Vec<f64> {
let n = phi.len();
let mut out = vec![0.0; n];
for c in 0..h.len() {
let hc = h[c];
if hc == 0.0 {
continue;
}
for j in 0..n {
out[j] += hc * phi[c][j];
}
}
out
}
pub fn observability_matrix(epochs: &[ObsEpoch]) -> (Mat, Vec<f64>) {
let mut o = Vec::new();
let mut w = Vec::new();
for ep in epochs {
for h in &ep.h {
o.push(row_times_matrix(h, &ep.phi));
w.push(ep.dt);
}
}
(o, w)
}
pub fn gramian(epochs: &[ObsEpoch]) -> Mat {
let (o, w) = observability_matrix(epochs);
information_matrix(&o, &w)
}
pub fn singular_values(o: &Mat) -> Vec<f64> {
if o.is_empty() {
return vec![];
}
let ones = vec![1.0; o.len()];
let gram = information_matrix(o, &ones);
let e = sym_eig(&gram);
let mut sv: Vec<f64> = e.values.iter().map(|&l| l.max(0.0).sqrt()).collect();
sv.sort_by(|a, b| b.total_cmp(a));
sv
}
pub fn observable_rank(o: &Mat, rel_tol: f64) -> usize {
let sv = singular_values(o);
let smax = sv.first().copied().unwrap_or(0.0);
if smax <= 0.0 {
return 0;
}
let thr = rel_tol * smax;
sv.iter().filter(|&&s| s > thr).count()
}
#[derive(Clone, Debug)]
pub struct GramianSpectrum {
pub eigenvalues: Vec<f64>,
pub min_eigenvalue: f64,
pub max_eigenvalue: f64,
pub trace: f64,
pub condition: f64,
pub rank: usize,
pub defect: usize,
}
pub fn gramian_spectrum(w: &Mat, rel_tol: f64) -> GramianSpectrum {
let e = sym_eig(w);
let dm = design_metrics(w, rel_tol);
let min_eigenvalue = e.values.first().copied().unwrap_or(0.0);
let max_eigenvalue = e.values.last().copied().unwrap_or(0.0);
let trace = e.values.iter().sum();
GramianSpectrum {
eigenvalues: e.values,
min_eigenvalue,
max_eigenvalue,
trace,
condition: dm.condition,
rank: dm.rank,
defect: dm.defect,
}
}
#[derive(Clone, Debug)]
pub struct RankArcPoint {
pub epoch_index: usize,
pub arc_time: f64,
pub n_rows: usize,
pub rank: usize,
pub sigma_max: f64,
pub sigma_min: f64,
}
pub fn rank_vs_arc(epochs: &[ObsEpoch], rel_tol: f64) -> Vec<RankArcPoint> {
let mut o: Mat = Vec::new();
let mut arc = 0.0;
let mut out = Vec::with_capacity(epochs.len());
for (k, ep) in epochs.iter().enumerate() {
arc += ep.dt;
for h in &ep.h {
o.push(row_times_matrix(h, &ep.phi));
}
let sv = singular_values(&o);
let sigma_max = sv.first().copied().unwrap_or(0.0);
let sigma_min = sv.last().copied().unwrap_or(0.0);
let rank = if sigma_max > 0.0 {
let thr = rel_tol * sigma_max;
sv.iter().filter(|&&s| s > thr).count()
} else {
0
};
out.push(RankArcPoint {
epoch_index: k,
arc_time: arc,
n_rows: o.len(),
rank,
sigma_max,
sigma_min,
});
}
out
}
#[derive(Clone, Debug)]
pub struct RankLever {
pub n_links: usize,
pub rank_range_only: usize,
pub rank_range_rate: usize,
}
pub fn range_vs_range_rate_rank(
chief: &PlanarState,
refs: &[PlanarState],
rel_tol: f64,
) -> RankLever {
let mut h_range: Mat = Vec::new();
let mut h_both: Mat = Vec::new();
for r in refs {
let (_rho, rr) = range_row(chief, r);
h_range.push(rr.to_vec());
h_both.push(rr.to_vec());
let (_rd, rrr) = range_rate_row(chief, r);
h_both.push(rrr.to_vec());
}
RankLever {
n_links: refs.len(),
rank_range_only: observable_rank(&h_range, rel_tol),
rank_range_rate: observable_rank(&h_both, rel_tol),
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum CislunarGdop {
Defined {
gdop: f64,
rank: usize,
},
Undefined {
rank: usize,
defect: usize,
reason: String,
},
}
pub fn cislunar_gdop(rows: &Mat, rel_tol: f64) -> CislunarGdop {
if rows.is_empty() {
return CislunarGdop::Undefined {
rank: 0,
defect: 0,
reason: "no measurement rows: geometry is empty".to_string(),
};
}
let n = rows[0].len();
let weights = vec![1.0; rows.len()];
let m = information_matrix(rows, &weights);
let dm = design_metrics(&m, rel_tol);
if dm.defect > 0 || !dm.condition.is_finite() {
return CislunarGdop::Undefined {
rank: dm.rank,
defect: n - dm.rank,
reason: format!(
"GDOP undefined (rank-deficient / singular geometry): rank {} of {} \
states, datum defect {}, condition {}",
dm.rank,
n,
n - dm.rank,
if dm.condition.is_finite() {
format!("{:.3e}", dm.condition)
} else {
"inf".to_string()
}
),
};
}
let c = crate::fim::crlb(&m, rel_tol);
let trace_inv: f64 = c.crlb_diag.iter().sum();
CislunarGdop::Defined {
gdop: trace_inv.max(0.0).sqrt(),
rank: dm.rank,
}
}
#[allow(clippy::needless_range_loop)]
pub fn determinant(m: &Mat) -> f64 {
let n = m.len();
if n == 0 {
return 1.0;
}
let mut a: Vec<Vec<f64>> = m.to_vec();
let mut det = 1.0;
for col in 0..n {
let mut piv = col;
let mut best = a[col][col].abs();
for r in (col + 1)..n {
let v = a[r][col].abs();
if v > best {
best = v;
piv = r;
}
}
if best == 0.0 {
return 0.0;
}
if piv != col {
a.swap(piv, col);
det = -det;
}
det *= a[col][col];
let pivot = a[col][col];
for r in (col + 1)..n {
let factor = a[r][col] / pivot;
if factor != 0.0 {
for c in col..n {
a[r][c] -= factor * a[col][c];
}
}
}
}
det
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cr3bp::EARTH_MOON_MU;
fn frob_sq(m: &Mat) -> f64 {
m.iter().flat_map(|r| r.iter()).map(|v| v * v).sum()
}
#[test]
fn planar_stm_matches_finite_difference() {
let s0: PlanarState = [1.08, 0.03, 0.10, -0.50];
let (t, steps) = (0.20, 4000);
let (_st, phi) = planar_state_stm(&s0, EARTH_MOON_MU, t, steps);
let eps = 1e-6;
for j in 0..N_PLANAR {
let mut sp = s0;
let mut sm = s0;
sp[j] += eps;
sm[j] -= eps;
let ep = planar_propagate(&sp, EARTH_MOON_MU, t, steps);
let em = planar_propagate(&sm, EARTH_MOON_MU, t, steps);
for i in 0..N_PLANAR {
let fd = (ep[i] - em[i]) / (2.0 * eps);
assert!(
(phi[i][j] - fd).abs() < 1e-5,
"planar STM[{i}][{j}] = {} vs finite-diff {fd}",
phi[i][j]
);
}
}
}
#[test]
#[allow(clippy::needless_range_loop)]
fn planar_stm_is_identity_at_zero_time() {
let s0: PlanarState = [1.1, 0.0, 0.0, -0.5];
let (_st, phi) = planar_state_stm(&s0, EARTH_MOON_MU, 0.0, 10);
for i in 0..N_PLANAR {
for j in 0..N_PLANAR {
let want = if i == j { 1.0 } else { 0.0 };
assert!((phi[i][j] - want).abs() < 1e-12);
}
}
}
#[test]
fn gramian_spectrum_satisfies_spectral_invariants() {
let epochs = sample_arc();
let w = gramian(&epochs);
let spec = gramian_spectrum(&w, 1e-9);
let sum: f64 = spec.eigenvalues.iter().sum();
let sum_sq: f64 = spec.eigenvalues.iter().map(|l| l * l).sum();
let prod: f64 = spec.eigenvalues.iter().product();
let tr: f64 = (0..w.len()).map(|i| w[i][i]).sum();
assert!(
(sum - tr).abs() <= 1e-9 * (1.0 + tr.abs()),
"trace {tr} vs Σλ {sum}"
);
assert!(
(sum_sq - frob_sq(&w)).abs() <= 1e-9 * (1.0 + frob_sq(&w)),
"Frobenius² {} vs Σλ² {sum_sq}",
frob_sq(&w)
);
let det = determinant(&w);
assert!(
(prod - det).abs() <= 1e-8 * (1.0 + det.abs()),
"det {det} vs Πλ {prod}"
);
assert!(spec.min_eigenvalue >= -1e-12);
}
#[test]
fn svd_rank_matches_gramian_eigen_rank() {
let epochs = sample_arc();
let (o, _w) = observability_matrix(&epochs);
let svd_rank = observable_rank(&o, 1e-9);
let w = gramian(&epochs);
let spec = gramian_spectrum(&w, 1e-9);
assert_eq!(svd_rank, spec.rank, "SVD rank vs Gramian eigen-rank");
}
fn sample_arc() -> Vec<ObsEpoch> {
let chief: PlanarState = [1.10, 0.02, 0.05, -0.50];
let reference: PlanarState = [1.02, -0.03, -0.06, -0.55];
let mu = EARTH_MOON_MU;
let ts = [0.02_f64, 0.05_f64];
let mut out = Vec::new();
let mut prev = 0.0;
for &t in &ts {
let (cs, phi) = planar_state_stm(&chief, mu, t, 2000);
let rs = planar_propagate(&reference, mu, t, 2000);
let (_rho, r_row) = range_row(&cs, &rs);
let (_rd, rr_row) = range_rate_row(&cs, &rs);
out.push(ObsEpoch {
h: vec![r_row.to_vec(), rr_row.to_vec()],
phi: phi.iter().map(|r| r.to_vec()).collect(),
dt: t - prev,
});
prev = t;
}
out
}
#[test]
fn range_rate_raises_instantaneous_rank() {
let chief: PlanarState = [1.10, 0.02, 0.05, -0.50];
let refs = [
[1.02, -0.03, -0.06, -0.55],
[1.15, 0.05, 0.10, -0.45],
[1.05, 0.06, 0.18, -0.40],
];
let lever = range_vs_range_rate_rank(&chief, &refs, 1e-9);
assert!(
lever.rank_range_rate > lever.rank_range_only,
"range+rate rank {} must exceed range-only rank {}",
lever.rank_range_rate,
lever.rank_range_only
);
assert!(lever.rank_range_only <= 2);
}
#[test]
fn range_only_single_link_is_rank_one() {
let chief: PlanarState = [1.10, 0.02, 0.05, -0.50];
let refs = [[1.02, -0.03, -0.06, -0.55]];
let lever = range_vs_range_rate_rank(&chief, &refs, 1e-9);
assert_eq!(lever.rank_range_only, 1, "one range snapshot is rank-1");
assert!(lever.rank_range_rate >= 2, "range+rate sees velocity too");
}
#[test]
fn rank_deficient_geometry_flags_gdop_undefined() {
let chief: PlanarState = [1.10, 0.02, 0.05, -0.50];
let refs = [[1.02, -0.03, -0.06, -0.55], [1.15, 0.05, 0.10, -0.45]];
let mut rows: Mat = Vec::new();
for r in &refs {
let (_rho, rr) = range_row(&chief, r);
rows.push(rr.to_vec());
}
match cislunar_gdop(&rows, 1e-9) {
CislunarGdop::Undefined { defect, .. } => assert!(defect >= 1),
CislunarGdop::Defined { gdop, .. } => {
panic!("rank-deficient geometry must not yield a finite GDOP {gdop}")
}
}
}
#[test]
fn full_rank_geometry_yields_finite_gdop() {
let chief: PlanarState = [1.10, 0.02, 0.05, -0.50];
let refs = [
[1.02, -0.03, -0.06, -0.55],
[1.15, 0.05, 0.10, -0.45],
[1.05, 0.06, 0.18, -0.40],
];
let mut rows: Mat = Vec::new();
for r in &refs {
let (_rho, rr) = range_row(&chief, r);
rows.push(rr.to_vec());
let (_rd, rrr) = range_rate_row(&chief, r);
rows.push(rrr.to_vec());
}
match cislunar_gdop(&rows, 1e-9) {
CislunarGdop::Defined { gdop, rank } => {
assert_eq!(rank, N_PLANAR);
assert!(gdop.is_finite() && gdop > 0.0, "GDOP {gdop}");
}
CislunarGdop::Undefined { reason, .. } => panic!("expected finite GDOP: {reason}"),
}
}
#[test]
fn determinant_matches_known_values() {
let id: Mat = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
assert!((determinant(&id) - 1.0).abs() < 1e-12);
let m: Mat = vec![vec![4.0, 3.0], vec![6.0, 3.0]];
assert!((determinant(&m) - (4.0 * 3.0 - 3.0 * 6.0)).abs() < 1e-12);
let sing: Mat = vec![vec![1.0, 2.0], vec![2.0, 4.0]];
assert!(determinant(&sing).abs() < 1e-12);
}
#[test]
fn rank_vs_arc_grows_and_reaches_full_rank() {
let chief: PlanarState = [1.10, 0.02, 0.05, -0.50];
let reference: PlanarState = [1.02, -0.03, -0.06, -0.55];
let mu = EARTH_MOON_MU;
let n_epochs = 24;
let arc = 0.06_f64; let mut epochs = Vec::new();
let mut prev = 0.0;
for k in 0..n_epochs {
let t = arc * (k as f64) / ((n_epochs - 1) as f64);
let (cs, phi) = planar_state_stm(&chief, mu, t, 3000);
let rs = planar_propagate(&reference, mu, t, 3000);
let (_rho, r_row) = range_row(&cs, &rs);
epochs.push(ObsEpoch {
h: vec![r_row.to_vec()],
phi: phi.iter().map(|r| r.to_vec()).collect(),
dt: t - prev,
});
prev = t;
}
let table = rank_vs_arc(&epochs, 1e-6);
assert_eq!(table[0].rank, 1, "single instantaneous range is rank-1");
for w in table.windows(2) {
assert!(
w[1].rank >= w[0].rank,
"rank must not decrease along the arc"
);
}
assert_eq!(
table.last().unwrap().rank,
N_PLANAR,
"full observability over arc"
);
}
}