use kshana::intersat_range::{range_row, PlanarState};
use kshana::observability_gramian::{
gramian, gramian_spectrum, observability_matrix, observable_rank, rank_vs_arc, Mat, ObsEpoch,
N_PLANAR,
};
const REF: &str =
include_str!("fixtures/observability_gramian/observability_gramian_reference.txt");
const TOL_REL: f64 = 1e-8;
const TOL_ABS: f64 = 1e-9;
const REL_TOL: f64 = 1e-6;
struct Fixture {
chief: PlanarState,
reference: PlanarState,
phis: Vec<Mat>,
dts: Vec<f64>,
rankarc: Vec<(usize, usize, usize, f64, f64)>, o_full_rank: usize,
gramian_eigs_ascending: Vec<f64>,
gramian_lambda_min: f64,
gramian_lambda_max: f64,
gramian_trace: f64,
gramian_condition: f64,
}
fn parse_state4(tokens: &[&str]) -> PlanarState {
assert_eq!(tokens.len(), 4, "need 4 numbers for a planar state");
[
tokens[0].parse().unwrap(),
tokens[1].parse().unwrap(),
tokens[2].parse().unwrap(),
tokens[3].parse().unwrap(),
]
}
fn parse_fixture(text: &str) -> Fixture {
let mut chief = None;
let mut reference = None;
let mut dts: Vec<f64> = Vec::new();
let mut phis: Vec<Mat> = Vec::new();
let mut cur_mat: Option<Mat> = None;
let mut cur_is_phi = false;
let mut rankarc = Vec::new();
let mut o_full_rank = None;
let mut eigs: Vec<f64> = Vec::new();
let mut lmin = None;
let mut lmax = None;
let mut trace = None;
let mut condition = None;
let flush_mat = |cur_mat: &mut Option<Mat>, cur_is_phi: &mut bool, phis: &mut Vec<Mat>| {
if let Some(m) = cur_mat.take() {
if *cur_is_phi {
phis.push(m);
}
*cur_is_phi = false;
}
};
for line in text.lines() {
let line = line.trim_end();
if let Some(rest) = line.strip_prefix("# CHIEF ") {
chief = Some(parse_state4(&rest.split_whitespace().collect::<Vec<_>>()));
} else if let Some(rest) = line.strip_prefix("# REF ") {
reference = Some(parse_state4(&rest.split_whitespace().collect::<Vec<_>>()));
} else if let Some(rest) = line.strip_prefix("# DTS ") {
dts = rest
.split_whitespace()
.map(|s| s.parse().unwrap())
.collect();
} else if let Some(rest) = line.strip_prefix("# MATRIX ") {
flush_mat(&mut cur_mat, &mut cur_is_phi, &mut phis);
cur_mat = Some(Vec::new());
cur_is_phi = rest.starts_with("PHI");
} else if let Some(rest) = line.strip_prefix("ROW ") {
if let Some(m) = cur_mat.as_mut() {
m.push(
rest.split_whitespace()
.map(|s| s.parse().unwrap())
.collect(),
);
}
} else if let Some(rest) = line.strip_prefix("RANKARC ") {
let t: Vec<&str> = rest.split_whitespace().collect();
rankarc.push((
t[0].parse().unwrap(),
t[1].parse().unwrap(),
t[2].parse().unwrap(),
t[3].parse().unwrap(),
t[4].parse().unwrap(),
));
} else if let Some(rest) = line.strip_prefix("# O_FULL_RANK ") {
o_full_rank = Some(rest.trim().parse().unwrap());
} else if let Some(rest) = line.strip_prefix("EIGS ") {
eigs = rest
.split_whitespace()
.map(|s| s.parse().unwrap())
.collect();
} else if let Some(rest) = line.strip_prefix("# GRAMIAN_LAMBDA_MIN ") {
lmin = Some(rest.trim().parse().unwrap());
} else if let Some(rest) = line.strip_prefix("# GRAMIAN_LAMBDA_MAX ") {
lmax = Some(rest.trim().parse().unwrap());
} else if let Some(rest) = line.strip_prefix("# GRAMIAN_TRACE ") {
trace = Some(rest.trim().parse().unwrap());
} else if let Some(rest) = line.strip_prefix("# GRAMIAN_CONDITION ") {
condition = Some(rest.trim().parse().unwrap());
}
}
flush_mat(&mut cur_mat, &mut cur_is_phi, &mut phis);
Fixture {
chief: chief.expect("CHIEF"),
reference: reference.expect("REF"),
phis,
dts,
rankarc,
o_full_rank: o_full_rank.expect("O_FULL_RANK"),
gramian_eigs_ascending: eigs,
gramian_lambda_min: lmin.expect("LAMBDA_MIN"),
gramian_lambda_max: lmax.expect("LAMBDA_MAX"),
gramian_trace: trace.expect("TRACE"),
gramian_condition: condition.expect("CONDITION"),
}
}
fn build_epochs(f: &Fixture) -> Vec<ObsEpoch> {
let (_rho, h_row) = range_row(&f.chief, &f.reference);
f.phis
.iter()
.zip(&f.dts)
.map(|(phi, &dt)| ObsEpoch {
h: vec![h_row.to_vec()],
phi: phi.clone(),
dt,
})
.collect()
}
fn close(got: f64, want: f64) -> bool {
(got - want).abs() <= TOL_ABS + TOL_REL * want.abs()
}
#[test]
fn rank_vs_arc_matches_numpy_matrix_rank() {
let f = parse_fixture(REF);
let epochs = build_epochs(&f);
let table = rank_vs_arc(&epochs, REL_TOL);
assert_eq!(
table.len(),
f.rankarc.len(),
"arc length mismatch vs fixture"
);
for (p, &(ei, nrows, rank, smax, smin)) in table.iter().zip(&f.rankarc) {
assert_eq!(p.epoch_index, ei, "epoch index");
assert_eq!(p.n_rows, nrows, "n_rows at epoch {ei}");
assert_eq!(
p.rank, rank,
"kshana rank {} != numpy.linalg.matrix_rank {} at epoch {ei}",
p.rank, rank
);
assert!(
close(p.sigma_max, smax),
"sigma_max {} vs numpy {} at epoch {ei}",
p.sigma_max,
smax
);
assert!(
close(p.sigma_min, smin),
"sigma_min {} vs numpy {} at epoch {ei}",
p.sigma_min,
smin
);
}
assert_eq!(table[0].rank, 1, "single instantaneous range is rank-1");
assert_eq!(
table.last().unwrap().rank,
N_PLANAR,
"full observability over the arc"
);
}
#[test]
fn full_arc_observable_rank_matches_numpy() {
let f = parse_fixture(REF);
let epochs = build_epochs(&f);
let (o, _w) = observability_matrix(&epochs);
let rank = observable_rank(&o, REL_TOL);
assert_eq!(
rank, f.o_full_rank,
"full-arc observable rank {rank} != numpy.linalg.matrix_rank {}",
f.o_full_rank
);
assert_eq!(rank, N_PLANAR);
}
#[test]
fn gramian_spectrum_matches_numpy_eigh_and_cond() {
let f = parse_fixture(REF);
let epochs = build_epochs(&f);
let w = gramian(&epochs);
let spec = gramian_spectrum(&w, REL_TOL);
assert_eq!(
spec.eigenvalues.len(),
f.gramian_eigs_ascending.len(),
"eigen count"
);
for (got, want) in spec.eigenvalues.iter().zip(&f.gramian_eigs_ascending) {
assert!(
close(*got, *want),
"Gramian eigenvalue {got} differs from numpy {want}"
);
}
assert!(
close(spec.min_eigenvalue, f.gramian_lambda_min),
"lambda_min {} vs numpy {}",
spec.min_eigenvalue,
f.gramian_lambda_min
);
assert!(
close(spec.max_eigenvalue, f.gramian_lambda_max),
"lambda_max {} vs numpy {}",
spec.max_eigenvalue,
f.gramian_lambda_max
);
assert!(
close(spec.trace, f.gramian_trace),
"trace {} vs numpy {}",
spec.trace,
f.gramian_trace
);
let cond_rel = (spec.condition - f.gramian_condition).abs() / f.gramian_condition;
assert!(
cond_rel < 1e-4,
"Gramian condition {} vs numpy {} (rel {cond_rel:.2e})",
spec.condition,
f.gramian_condition
);
eprintln!(
"observability_gramian_reference: kshana vs numpy — full rank {}, lambda [{:.3e} .. {:.3e}], \
cond {:.3e} (numpy {:.3e})",
spec.rank, spec.min_eigenvalue, spec.max_eigenvalue, spec.condition, f.gramian_condition
);
}