1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
// SPDX-License-Identifier: AGPL-3.0-only
//! Independent SRIF cross-validation of the cislunar observability transition (paper P6, L32).
//!
//! The observability core [`crate::observability_gramian`] reads *how much* of a
//! spacecraft's planar four-state an inter-satellite-ranging arc constrains from the **rank**
//! of the stacked observability matrix `O = stack_k[H_k·Φ_k]` and the **conditioning** of its
//! Gram matrix, both via the crate's Jacobi eigensolver. That is one estimator's verdict. This
//! module cross-checks that verdict against a *second, independent* estimator — the crate's
//! **Square-Root Information Filter** [`crate::deepspace_od::Srif`], which never forms `OᵀO`:
//! it folds the observability rows one at a time through **Householder triangularization** into
//! an upper-triangular information square root `R`, and recovers the posterior covariance as the
//! Gram matrix `P = R⁻¹R⁻ᵀ`.
//!
//! Folding the `k`-th observability row `H_k·Φ_k` (the partial of the `k`-th range with respect
//! to the *initial* planar state, by the chain rule through the variational STM `Φ_k`) into a
//! fully-diffuse SRIF with unit weight makes `RᵀR = Σ_k (H_kΦ_k)ᵀ(H_kΦ_k) = OᵀO` — the same
//! information content the observability Gramian carries, reached by a completely different
//! numerical machine. The cross-validation is then sharp:
//!
//! * the SRIF **posterior covariance becomes finite / well-conditioned exactly when the
//! observable rank reaches the full four-state** — below full rank `R` has a zero pivot, so
//! `P` is singular (infinite condition); at full rank `P = (OᵀO)⁻¹` is finite; and
//! * the SRIF posterior-covariance **condition number tracks the Gramian conditioning** as the
//! arc grows — `cond(P) = cond((OᵀO)⁻¹) = cond(OᵀO)`, so the two independent estimators agree
//! on the geometry's conditioning to numerical precision.
//!
//! This upgrades P6's rank-transition claim from *rank-only* to *Validated against an
//! independent estimator*: the SRIF, which shares no code with the eigen-Gramian beyond the
//! measurement partials, confirms the same transition arc and the same conditioning.
//!
//! ## Validated vs Modelled
//! * **Validated.** The rank at which the SRIF posterior covariance turns finite equals the
//! observability-matrix rank transition, and its condition number equals the observability-Gram
//! condition number — two independent routes (Householder square-root information filtering vs
//! the Jacobi eigen-Gramian) agreeing on the same subspace and conditioning (the module's unit
//! tests assert both). Thresholds are consistent (a relative singular-value floor, applied as
//! its square on the eigenvalue side), so the two rank reads cannot silently disagree.
//! * **Modelled.** The tracking geometry (which spacecraft, links, arc, epoch grid) is the same
//! Modelled scenario input as the rest of P6; the *specific* transition arc is a property of
//! that geometry, not a certified universal.
use crate::deepspace_od::Srif;
use crate::fim::{information_matrix, sym_eig};
use crate::observability_gramian::{
observability_matrix, singular_values, Mat, ObsEpoch, N_PLANAR,
};
/// One arc point of the SRIF ↔ observability-Gramian cross-validation: the observable rank and
/// conditioning read two independent ways over the tracking arc truncated at `epoch_index`.
#[derive(Clone, Debug)]
pub struct SrifArcPoint {
/// Index of the last epoch in this growing prefix.
pub epoch_index: usize,
/// Elapsed arc time from the first epoch (normalised rotating-frame time units).
pub arc_time: f64,
/// Stacked observability rows accumulated up to and including this epoch.
pub n_rows: usize,
/// Observable rank from the observability matrix `O` (singular-value threshold) — the same
/// rank the P6 `rank-vs-arc` table reports.
pub gramian_rank: usize,
/// Full-space condition number of the observability Gram `OᵀO` (`+∞` below full rank): the
/// eigen-Gramian estimator's conditioning verdict.
pub gramian_condition: f64,
/// Whether the SRIF posterior covariance `P = R⁻¹R⁻ᵀ` is finite **and** well-conditioned
/// (full rank): `true` exactly when the observable rank reaches the full four-state.
pub srif_posterior_wellposed: bool,
/// SRIF posterior-covariance condition number (`+∞` when rank-deficient / singular). At full
/// rank this equals [`Self::gramian_condition`] to numerical precision.
pub srif_condition: f64,
}
/// Full-space condition number `λ_max/λ_min` of a symmetric PSD spectrum (ascending), with a
/// relative floor `floor·λ_max`: a smallest eigenvalue at or below the floor means the matrix is
/// singular on the full space, so the condition is `+∞`.
fn floored_condition(eigs_ascending: &[f64], floor: f64) -> f64 {
let lmax = eigs_ascending.last().copied().unwrap_or(0.0);
let lmin = eigs_ascending.first().copied().unwrap_or(0.0);
if lmax <= 0.0 || lmin <= floor * lmax {
f64::INFINITY
} else {
lmax / lmin
}
}
/// Fold the stacked observability rows into a fully-diffuse SRIF over the initial planar state
/// with **unit weight**, so the resulting information square root satisfies `RᵀR = OᵀO`. The
/// measurement residual is irrelevant to the covariance, so a zero residual is folded on each
/// row; `σ = 1` gives the unit-weight Gram. `R` is reached by Householder triangularization —
/// no normal matrix is ever formed.
fn srif_over_arc(o: &Mat, n: usize) -> Srif {
let mut srif = Srif::new(n);
for row in o {
srif.measurement_update(row, 0.0, 1.0);
}
srif
}
/// The SRIF ↔ observability-Gramian cross-validation over growing prefixes of the epoch
/// sequence. For each prefix the observable rank and conditioning are read from the observability
/// matrix (singular-value threshold, the eigen-Gramian route) and, **independently**, from a
/// diffuse SRIF folded with the same observability rows (Householder square-root information
/// filtering). `rel_tol` is the relative singular-value floor (applied as its square on the
/// eigenvalue side, so the two rank reads use a consistent threshold).
pub fn srif_cross_validation(epochs: &[ObsEpoch], rel_tol: f64) -> Vec<SrifArcPoint> {
let floor = rel_tol * rel_tol; // singular-value floor rel_tol ⇒ eigenvalue floor rel_tol²
let mut out = Vec::with_capacity(epochs.len());
let mut arc = 0.0;
for (k, ep) in epochs.iter().enumerate() {
arc += ep.dt;
let sub = &epochs[..=k];
let (o, _w) = observability_matrix(sub);
// Eigen-Gramian estimator: rank via singular-value threshold, condition of OᵀO.
let sv = singular_values(&o);
let smax = sv.first().copied().unwrap_or(0.0);
let gramian_rank = if smax > 0.0 {
sv.iter().filter(|&&s| s > rel_tol * smax).count()
} else {
0
};
let ones = vec![1.0; o.len()];
let gram = information_matrix(&o, &ones);
let gram_eig = sym_eig(&gram);
let gramian_condition = if gramian_rank == N_PLANAR {
floored_condition(&gram_eig.values, floor)
} else {
f64::INFINITY
};
// Independent SRIF estimator: posterior covariance finiteness + condition.
let srif = srif_over_arc(&o, N_PLANAR);
let (_x, p) = srif.solve();
let p_finite = p.iter().flatten().all(|v| v.is_finite());
let p_eig = sym_eig(&p);
let srif_condition = if p_finite {
floored_condition(&p_eig.values, floor)
} else {
f64::INFINITY
};
let srif_posterior_wellposed = srif_condition.is_finite();
out.push(SrifArcPoint {
epoch_index: k,
arc_time: arc,
n_rows: o.len(),
gramian_rank,
gramian_condition,
srif_posterior_wellposed,
srif_condition,
});
}
out
}
/// The first arc index at which the observable rank reaches the full four-state, or `None` if it
/// never does over the arc — the P6 transition the SRIF is cross-checked against.
pub fn full_rank_transition(points: &[SrifArcPoint]) -> Option<usize> {
points
.iter()
.find(|p| p.gramian_rank == N_PLANAR)
.map(|p| p.epoch_index)
}
/// The first arc index at which the independent SRIF posterior covariance turns finite /
/// well-conditioned, or `None`.
pub fn srif_finite_transition(points: &[SrifArcPoint]) -> Option<usize> {
points
.iter()
.find(|p| p.srif_posterior_wellposed)
.map(|p| p.epoch_index)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cr3bp::EARTH_MOON_MU;
use crate::intersat_range::{range_row, PlanarState};
use crate::observability_gramian::{planar_propagate, planar_state_stm};
/// A single-link range-only arc (chief ↔ reference): each epoch carries the chief STM and
/// one range row — the P6 arc that grows from rank-1 toward the full four-state.
fn single_link_arc(n_epochs: usize, arc: f64) -> 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 mut epochs = Vec::with_capacity(n_epochs);
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;
}
epochs
}
// ── ORACLE (Validated): the independent SRIF agrees with the rank transition ──
/// (a) at a short arc the SRIF information is singular (posterior covariance non-finite /
/// infinite condition); (b) at the arc where the observable rank reaches four the SRIF
/// posterior covariance is finite and its condition number is finite and the *same order* as
/// the observability-Gram condition number.
#[test]
fn srif_posterior_finite_exactly_at_full_rank() {
let rel_tol = 1e-6;
let epochs = single_link_arc(24, 0.06);
let points = srif_cross_validation(&epochs, rel_tol);
// (a) A short arc (first two epochs) is rank-deficient: the SRIF posterior is singular.
let short = &points[1];
assert!(
short.gramian_rank < N_PLANAR,
"short arc should be rank-deficient, got rank {}",
short.gramian_rank
);
assert!(
!short.srif_posterior_wellposed && short.srif_condition.is_infinite(),
"short-arc SRIF posterior must be singular (cond {})",
short.srif_condition
);
// (b) By the end of the arc the four-state is fully observable and the SRIF posterior is
// finite, well-conditioned, and the same order as the Gram condition.
let full = points.last().unwrap();
assert_eq!(full.gramian_rank, N_PLANAR, "full observability over arc");
assert!(
full.srif_posterior_wellposed && full.srif_condition.is_finite(),
"full-rank SRIF posterior must be finite (cond {})",
full.srif_condition
);
assert!(
full.gramian_condition.is_finite(),
"full-rank Gram condition must be finite"
);
// cond(P) = cond((OᵀO)⁻¹) = cond(OᵀO): the two independent estimators agree to well
// within an order of magnitude (in practice to a few ULP of the eigensolvers).
let ratio = full.srif_condition / full.gramian_condition;
assert!(
(0.1..=10.0).contains(&ratio),
"SRIF condition {:.3e} not the same order as Gram condition {:.3e} (ratio {ratio:.3e})",
full.srif_condition,
full.gramian_condition
);
// The independent transition arcs coincide: the SRIF posterior turns finite exactly at
// the rank-4 arc — the cross-validation's core claim. The wellposedness floor is the
// singular-value floor transported to the covariance spectrum, so the two transitions are
// threshold-consistent, not coincidentally aligned.
assert_eq!(
full_rank_transition(&points),
srif_finite_transition(&points),
"SRIF posterior-finite transition must equal the observable rank-4 transition"
);
}
/// The posterior condition number is monotone-ish informative: once the SRIF is well-posed it
/// stays well-posed as the arc only adds information (rank is non-decreasing).
#[test]
fn wellposed_is_persistent_along_the_arc() {
let points = srif_cross_validation(&single_link_arc(24, 0.06), 1e-6);
let mut seen_wellposed = false;
for p in &points {
if p.srif_posterior_wellposed {
seen_wellposed = true;
}
if seen_wellposed {
assert!(
p.srif_posterior_wellposed,
"SRIF lost well-posedness at epoch {} after gaining it",
p.epoch_index
);
}
}
assert!(
seen_wellposed,
"the arc should reach a well-posed posterior"
);
}
}