use crate::fim::{crlb, design_metrics, information_matrix, sym_eig};
use crate::intersat_range::{range_rate_row, range_row, PlanarState, SpatialState};
pub type Mat = Vec<Vec<f64>>;
pub const N_PLANAR: usize = 4;
pub const N_SPATIAL: usize = 6;
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]]
}
pub fn spatial_state_stm(
s0: &SpatialState,
mu: f64,
t: f64,
steps: usize,
) -> (SpatialState, [[f64; N_SPATIAL]; N_SPATIAL]) {
let embed = crate::cr3bp::Cr3bpState {
r: [s0[0], s0[1], s0[2]],
v: [s0[3], s0[4], s0[5]],
};
let (st, phi) = crate::cr3bp::propagate_state_stm(&embed, mu, t, steps);
([st.r[0], st.r[1], st.r[2], st.v[0], st.v[1], st.v[2]], phi)
}
pub fn spatial_propagate(s0: &SpatialState, mu: f64, t: f64, steps: usize) -> SpatialState {
let embed = crate::cr3bp::Cr3bpState {
r: [s0[0], s0[1], s0[2]],
v: [s0[3], s0[4], s0[5]],
};
let st = crate::cr3bp::propagate_cr3bp(embed, mu, t, steps);
[st.r[0], st.r[1], st.r[2], st.v[0], st.v[1], st.v[2]]
}
#[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 rank_from_singular_values(sv_descending: &[f64], rel_tol: f64) -> usize {
let smax = sv_descending.first().copied().unwrap_or(0.0);
if smax <= 0.0 {
return 0;
}
let thr = rel_tol * smax;
sv_descending.iter().filter(|&&s| s > thr).count()
}
pub const RANK_TOLERANCE_NOISE_FLOOR: f64 = 1.490_116_119_384_765_6e-8;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BoundedRank {
pub rank: usize,
pub counted: usize,
pub shape_bound: usize,
pub reason: Option<String>,
}
pub fn bounded_rank_from_singular_values(
sv_descending: &[f64],
rel_tol: f64,
n_rows: usize,
n_cols: usize,
) -> BoundedRank {
let counted = rank_from_singular_values(sv_descending, rel_tol);
let shape_bound = n_rows.min(n_cols);
if counted <= shape_bound {
return BoundedRank {
rank: counted,
counted,
shape_bound,
reason: None,
};
}
let floor_note = if rel_tol < RANK_TOLERANCE_NOISE_FLOOR {
format!(
" The tolerance is below the f64 rank-read noise floor {RANK_TOLERANCE_NOISE_FLOOR:.3e} \
(= sqrt(f64::EPSILON)): its equivalent eigenvalue floor rel_tol^2 = {:.3e} sits under \
the rounding noise of the Gram matrix, so the extra directions are arithmetic \
residue, not observability.",
rel_tol * rel_tol
)
} else {
String::new()
};
BoundedRank {
rank: shape_bound,
counted,
shape_bound,
reason: Some(format!(
"rank clamped from the singular-value count {counted} to min(rows, cols) = \
min({n_rows}, {n_cols}) = {shape_bound} at rel_tol {rel_tol:.3e}: a {n_rows}x{n_cols} \
matrix cannot have rank {counted}.{floor_note}"
)),
}
}
pub fn rank_tolerance_note(rel_tol: f64) -> Option<String> {
if rel_tol >= RANK_TOLERANCE_NOISE_FLOOR || rel_tol <= 0.0 {
return None;
}
Some(format!(
"rel_tol {rel_tol:.3e} is below the f64 rank-read noise floor \
{RANK_TOLERANCE_NOISE_FLOOR:.3e} (= sqrt(f64::EPSILON)). Ranks are read as \
sigma > rel_tol*sigma_max on sigma = sqrt(lambda(O^T O)), so the equivalent eigenvalue \
floor is rel_tol^2 = {:.3e}; below sqrt(f64::EPSILON) that floor sits under the rounding \
noise of the Gram matrix and the count can include directions that are arithmetic \
residue. The tolerance asked for is used unchanged; every read is additionally held to \
rank <= min(rows, cols), and any read that hits that bound says so.",
rel_tol * rel_tol
))
}
pub fn observable_rank(o: &Mat, rel_tol: f64) -> usize {
bounded_observable_rank(o, rel_tol).rank
}
pub fn bounded_observable_rank(o: &Mat, rel_tol: f64) -> BoundedRank {
let sv = singular_values(o);
let n_cols = o.first().map(|r| r.len()).unwrap_or(0);
bounded_rank_from_singular_values(&sv, rel_tol, o.len(), n_cols)
}
#[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 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();
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));
let rank = rank_from_singular_values(&sv, rel_tol);
let n = w.len();
let defect = n.saturating_sub(rank);
let condition = if rank == 0 {
f64::INFINITY
} else {
let lmin_obs = e.values[n - rank];
if lmin_obs > 0.0 {
max_eigenvalue / lmin_obs
} else {
f64::INFINITY
}
};
GramianSpectrum {
eigenvalues: e.values,
min_eigenvalue,
max_eigenvalue,
trace,
condition,
rank,
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 rank_limited_by: Option<String>,
}
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 n_cols = o.first().map(|r| r.len()).unwrap_or(0);
let read = bounded_rank_from_singular_values(&sv, rel_tol, o.len(), n_cols);
out.push(RankArcPoint {
epoch_index: k,
arc_time: arc,
n_rows: o.len(),
rank: read.rank,
sigma_max,
sigma_min,
rank_limited_by: read.reason,
});
}
out
}
pub fn whiten_epochs(epochs: &[ObsEpoch], sigma: f64) -> Vec<ObsEpoch> {
let scale = if sigma.is_finite() && sigma > 0.0 {
1.0 / sigma
} else {
1.0
};
epochs
.iter()
.map(|ep| ObsEpoch {
h: ep
.h
.iter()
.map(|row| row.iter().map(|v| v * scale).collect())
.collect(),
phi: ep.phi.clone(),
dt: ep.dt,
})
.collect()
}
#[derive(Clone, Debug)]
pub struct WhitenedPosterior {
pub rank: usize,
pub defect: usize,
pub condition: f64,
pub sigma_state: Vec<f64>,
pub sigma_position: Option<f64>,
pub sigma_velocity: Option<f64>,
pub null_space: Mat,
pub rank_limited_by: Option<String>,
}
pub fn whitened_posterior(o: &Mat, n_pos: usize, rel_tol: f64) -> WhitenedPosterior {
if o.is_empty() || o[0].is_empty() {
return WhitenedPosterior {
rank: 0,
defect: 0,
condition: f64::INFINITY,
sigma_state: vec![],
sigma_position: None,
sigma_velocity: None,
null_space: vec![],
rank_limited_by: None,
};
}
let n = o[0].len();
let ones = vec![1.0; o.len()];
let gram = information_matrix(o, &ones);
let c = crlb(&gram, rel_tol * rel_tol);
let shape_bound = o.len().min(n);
let mut rank_limited_by: Option<String> = None;
let c = if c.rank > shape_bound {
let mut sv: Vec<f64> = c.eigenvalues.iter().map(|&l| l.max(0.0).sqrt()).collect();
sv.sort_by(|a, b| b.total_cmp(a));
rank_limited_by = bounded_rank_from_singular_values(&sv, rel_tol, o.len(), n).reason;
let lmax = c.eigenvalues.last().copied().unwrap_or(0.0);
let smallest_kept = c.eigenvalues[n - shape_bound];
let largest_dropped = c.eigenvalues[n - shape_bound - 1];
let rel = if lmax > 0.0 {
if largest_dropped > 0.0 {
(smallest_kept * largest_dropped).sqrt() / lmax
} else {
0.5 * smallest_kept / lmax
}
} else {
rel_tol * rel_tol
};
crlb(&gram, rel)
} else {
c
};
let lmax = c.eigenvalues.last().copied().unwrap_or(0.0);
let condition = if c.rank == 0 {
f64::INFINITY
} else {
let lmin_obs = c.eigenvalues[n - c.rank];
if lmin_obs > 0.0 {
lmax / lmin_obs
} else {
f64::INFINITY
}
};
let full = c.defect == 0;
let rss = |lo: usize, hi: usize| -> Option<f64> {
if !full {
return None;
}
let s: f64 = c.crlb_diag[lo..hi].iter().map(|v| v.max(0.0)).sum();
Some(s.sqrt())
};
let n_pos = n_pos.min(n);
WhitenedPosterior {
rank: c.rank,
defect: c.defect,
condition,
sigma_state: c.crlb_std.clone(),
sigma_position: rss(0, n_pos),
sigma_velocity: rss(n_pos, n),
null_space: c.null_space,
rank_limited_by,
}
}
#[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 spatial_stm_matches_finite_difference() {
let s0: SpatialState = [1.05, 0.02, -0.10, 0.10, 0.20, -0.05];
let (t, steps) = (0.20, 4000);
let (_st, phi) = spatial_state_stm(&s0, EARTH_MOON_MU, t, steps);
let eps = 1e-6;
for j in 0..N_SPATIAL {
let mut sp = s0;
let mut sm = s0;
sp[j] += eps;
sm[j] -= eps;
let ep = spatial_propagate(&sp, EARTH_MOON_MU, t, steps);
let em = spatial_propagate(&sm, EARTH_MOON_MU, t, steps);
for i in 0..N_SPATIAL {
let fd = (ep[i] - em[i]) / (2.0 * eps);
assert!(
(phi[i][j] - fd).abs() < 1e-5,
"spatial STM[{i}][{j}] = {} vs finite-diff {fd}",
phi[i][j]
);
}
}
}
#[test]
fn planar_stm_is_the_spatial_stm_sub_block() {
let s4: PlanarState = [1.08, 0.03, 0.10, -0.50];
let s6: SpatialState = [s4[0], s4[1], 0.0, s4[2], s4[3], 0.0];
let (t, steps) = (0.05, 2000);
let (_a, phi4) = planar_state_stm(&s4, EARTH_MOON_MU, t, steps);
let (_b, phi6) = spatial_state_stm(&s6, EARTH_MOON_MU, t, steps);
let idx = [0usize, 1, 3, 4];
for (i, &ri) in idx.iter().enumerate() {
for (j, &cj) in idx.iter().enumerate() {
assert_eq!(phi4[i][j], phi6[ri][cj], "sub-block [{i}][{j}]");
}
}
}
#[test]
fn out_of_plane_block_decouples_at_z_zero() {
let s6: SpatialState = [1.08, 0.03, 0.0, 0.10, -0.50, 0.0];
let (_st, phi) = spatial_state_stm(&s6, EARTH_MOON_MU, 0.05, 2000);
let inplane = [0usize, 1, 3, 4];
let outplane = [2usize, 5];
for &i in &inplane {
for &j in &outplane {
assert_eq!(
phi[i][j], 0.0,
"Φ[{i}][{j}] couples out-of-plane into plane"
);
assert_eq!(
phi[j][i], 0.0,
"Φ[{j}][{i}] couples plane into out-of-plane"
);
}
}
}
#[test]
fn homoscedastic_whitening_leaves_rank_and_condition_invariant() {
let epochs = sample_arc();
let (o0, _) = observability_matrix(&epochs);
let spec0 = gramian_spectrum(&gramian(&epochs), 1e-9);
for &sigma in &[1e-3_f64, 1e-6, 1e-9, 1e-12] {
let w = whiten_epochs(&epochs, sigma);
let (o1, _) = observability_matrix(&w);
assert_eq!(
observable_rank(&o0, 1e-9),
observable_rank(&o1, 1e-9),
"rank moved under whitening at σ = {sigma}"
);
let spec1 = gramian_spectrum(&gramian(&w), 1e-9);
assert_eq!(spec0.rank, spec1.rank);
assert_eq!(spec0.defect, spec1.defect);
let ratio = spec1.condition / spec0.condition;
assert!(
(ratio - 1.0).abs() < 1e-6,
"condition moved under whitening at σ = {sigma}: ratio {ratio}"
);
}
let free = whiten_epochs(&epochs, 0.0);
let (o_free, _) = observability_matrix(&free);
assert_eq!(o_free, o0);
}
#[test]
fn posterior_sigma_scales_linearly_with_measurement_sigma() {
let epochs = sample_arc();
let (o1, _) = observability_matrix(&whiten_epochs(&epochs, 1e-9));
let (o2, _) = observability_matrix(&whiten_epochs(&epochs, 2e-9));
let p1 = whitened_posterior(&o1, 2, 1e-9);
let p2 = whitened_posterior(&o2, 2, 1e-9);
assert_eq!(p1.rank, p2.rank);
let (s1, s2) = (p1.sigma_position.unwrap(), p2.sigma_position.unwrap());
assert!(
((s2 / s1) - 2.0).abs() < 1e-9,
"posterior σ ratio {} is not 2 (σ doubled)",
s2 / s1
);
}
#[test]
fn rank_deficient_posterior_is_none_with_a_null_space() {
let chief: PlanarState = [1.10, 0.02, 0.05, -0.50];
let reference: PlanarState = [1.02, -0.03, -0.06, -0.55];
let (_rho, row) = range_row(&chief, &reference);
let o: Mat = vec![row.to_vec()];
let p = whitened_posterior(&o, 2, 1e-9);
assert_eq!(p.rank, 1);
assert_eq!(p.defect, 3);
assert!(p.sigma_position.is_none() && p.sigma_velocity.is_none());
assert_eq!(p.null_space.len(), N_PLANAR);
assert_eq!(p.null_space[0].len(), 3);
assert!(
(p.condition - 1.0).abs() < 1e-12,
"condition {}",
p.condition
);
}
#[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"
);
}
#[test]
fn the_rank_tolerance_noise_floor_is_the_square_root_of_machine_epsilon() {
assert_eq!(RANK_TOLERANCE_NOISE_FLOOR, f64::EPSILON.sqrt());
}
#[test]
fn a_two_row_matrix_cannot_report_rank_three() {
let o: Mat = vec![vec![1.0, 2.0, 3.0, 4.0], vec![4.0, 3.0, 2.0, 1.0]];
let sv = singular_values(&o);
let counted = rank_from_singular_values(&sv, 1e-14);
assert!(
counted > 2,
"setup: the unbounded count must exceed the row count for this to test anything \
(counted {counted}, spectrum {sv:?})"
);
let read = bounded_rank_from_singular_values(&sv, 1e-14, o.len(), 4);
assert_eq!(read.rank, 2, "rank is held to min(rows, cols) = 2");
assert_eq!(read.counted, counted);
assert_eq!(read.shape_bound, 2);
let reason = read.reason.expect("a clamp must state its reason");
assert!(
reason.contains("min(2, 4) = 2") && reason.contains("noise floor"),
"the stated reason must name the bound and the floor: {reason}"
);
assert_eq!(observable_rank(&o, 1e-14), 2);
let post = whitened_posterior(&o, 2, 1e-14);
assert_eq!(post.rank, 2);
assert_eq!(post.defect, 2);
assert_eq!(post.null_space[0].len(), 2, "null space matches the defect");
assert!(
post.sigma_position.is_none() && post.sigma_velocity.is_none(),
"an underdetermined batch has no finite covariance"
);
assert!(post.rank_limited_by.is_some(), "the clamp states itself");
}
#[test]
fn the_bound_changes_nothing_at_any_shipped_tolerance() {
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 = 12;
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, 1500);
let rs = planar_propagate(&reference, mu, t, 1500);
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;
}
for rel_tol in [1e-4_f64, 1e-5, 1e-6, 1e-7, 1e-8] {
assert!(
rel_tol >= RANK_TOLERANCE_NOISE_FLOOR || rel_tol == 1e-8,
"the shipped tolerances sit at or above the noise floor"
);
let table = rank_vs_arc(&epochs, rel_tol);
for (k, point) in table.iter().enumerate() {
let (o, _w) = observability_matrix(&epochs[..=k]);
let unbounded = rank_from_singular_values(&singular_values(&o), rel_tol);
assert_eq!(
point.rank, unbounded,
"rel_tol {rel_tol:e}, prefix {k}: the bound moved a shipped value"
);
assert!(
point.rank_limited_by.is_none(),
"rel_tol {rel_tol:e}, prefix {k}: nothing should be clamped here"
);
}
}
}
#[test]
fn a_tolerance_below_the_noise_floor_is_reported_not_rewritten() {
assert!(rank_tolerance_note(1e-6).is_none());
assert!(rank_tolerance_note(RANK_TOLERANCE_NOISE_FLOOR).is_none());
let note = rank_tolerance_note(1e-12).expect("a sub-floor tolerance must be named");
assert!(
note.contains("1.000e-12") && note.contains("1.490e-8"),
"the note must quote both the tolerance and the floor: {note}"
);
let o: Mat = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
assert_eq!(observable_rank(&o, 1e-6), 2);
}
}