Skip to main content

sidereon_core/
geometry_quality.rs

1//! Geometry observability and residual-validation classification.
2
3/// Observability and validation tier for an estimation geometry.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum ObservabilityTier {
6    /// The design rank is below the parameter count, so at least one parameter
7    /// is not observable.
8    RankDeficient,
9    /// The design is full rank, but has no residual degrees of freedom.
10    ZeroRedundancy,
11    /// The design is full rank with residual degrees of freedom, but exceeds a
12    /// configured condition-number or GDOP cutoff.
13    Weak,
14    /// The design is full rank and does not exceed the configured cutoffs.
15    Nominal,
16}
17
18/// Geometry observability and covariance-validation diagnostics.
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct GeometryQuality {
21    /// Tier assigned from rank, redundancy, condition number, GDOP, and prior
22    /// availability.
23    pub tier: ObservabilityTier,
24    /// Observation redundancy, defined as `n_obs - n_params`.
25    pub redundancy: i32,
26    /// Rank of the design matrix used by the solve.
27    pub rank: usize,
28    /// Condition number of the design matrix, computed as `sigma_max /
29    /// sigma_min` from its singular values.
30    pub condition_number: f64,
31    /// Geometric dilution of precision for the solved state.
32    pub gdop: f64,
33    /// Whether residual-based RAIM can test the solve.
34    pub raim_checkable: bool,
35    /// Whether residuals or a valid propagated prior can validate the
36    /// covariance bound.
37    pub covariance_validated: bool,
38}
39
40/// Configurable cutoffs for [`classify`].
41///
42/// The default uses `cond_cutoff = 1.0e8` and `gdop_cutoff = 10.0`.
43/// The condition-number cutoff follows the standard first-order linear-system
44/// error amplifier, where `kappa(H) * eps` approximates relative numerical
45/// sensitivity. In `f64`, `1.0e8` is far above ordinary scaling noise but still
46/// below the singular-value rank threshold used by the least-squares covariance
47/// path. The GDOP cutoff sits in the common GNSS screening band around 6 to 20;
48/// `10.0` marks a geometry before a DOP-only projection becomes very large.
49#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct GeometryQualityThresholds {
51    /// Maximum accepted singular-value condition number for a full-rank solve
52    /// with positive redundancy.
53    pub cond_cutoff: f64,
54    /// Maximum accepted GDOP for a full-rank solve with positive redundancy.
55    pub gdop_cutoff: f64,
56}
57
58impl Default for GeometryQualityThresholds {
59    fn default() -> Self {
60        Self {
61            cond_cutoff: 1.0e8,
62            gdop_cutoff: 10.0,
63        }
64    }
65}
66
67/// Classify geometry observability and covariance validation from scalar
68/// diagnostics.
69///
70/// `rank` is compared to `n_params`. `redundancy` is `n_obs - n_params`.
71/// `condition_number` must be the singular-value ratio of the design matrix,
72/// not the condition number of the normal matrix. A non-finite condition number,
73/// GDOP, or cutoff is treated as exceeding the corresponding cutoff for
74/// full-rank positive-redundancy cases.
75pub fn classify(
76    rank: usize,
77    n_params: usize,
78    redundancy: i32,
79    condition_number: f64,
80    gdop: f64,
81    has_valid_prior: bool,
82    thresholds: GeometryQualityThresholds,
83) -> GeometryQuality {
84    let (tier, raim_checkable, covariance_validated) = if rank < n_params {
85        (ObservabilityTier::RankDeficient, false, false)
86    } else if redundancy == 0 {
87        (ObservabilityTier::ZeroRedundancy, false, has_valid_prior)
88    } else if redundancy >= 1
89        && (exceeds_cutoff(condition_number, thresholds.cond_cutoff)
90            || exceeds_cutoff(gdop, thresholds.gdop_cutoff))
91    {
92        (ObservabilityTier::Weak, true, true)
93    } else {
94        let raim_checkable = redundancy >= 1;
95        (
96            ObservabilityTier::Nominal,
97            raim_checkable,
98            raim_checkable || has_valid_prior,
99        )
100    };
101
102    GeometryQuality {
103        tier,
104        redundancy,
105        rank,
106        condition_number,
107        gdop,
108        raim_checkable,
109        covariance_validated,
110    }
111}
112
113fn exceeds_cutoff(value: f64, cutoff: f64) -> bool {
114    !value.is_finite() || !cutoff.is_finite() || value > cutoff
115}
116
117#[cfg(test)]
118mod tests {
119    //! Clean-room tests derived from estimation-theory classification rules.
120    //! Redundancy, rank, condition number, GDOP, and prior availability are
121    //! explicit scalar inputs; expected tiers do not come from a solve.
122
123    use super::*;
124
125    fn thresholds() -> GeometryQualityThresholds {
126        GeometryQualityThresholds {
127            cond_cutoff: 100.0,
128            gdop_cutoff: 10.0,
129        }
130    }
131
132    #[test]
133    fn zero_redundancy_without_prior_is_not_validated() {
134        let quality = classify(4, 4, 0, 3.0, 2.0, false, thresholds());
135
136        assert_eq!(
137            quality,
138            GeometryQuality {
139                tier: ObservabilityTier::ZeroRedundancy,
140                redundancy: 0,
141                rank: 4,
142                condition_number: 3.0,
143                gdop: 2.0,
144                raim_checkable: false,
145                covariance_validated: false,
146            }
147        );
148    }
149
150    #[test]
151    fn zero_redundancy_with_prior_is_validated() {
152        let quality = classify(4, 4, 0, 3.0, 2.0, true, thresholds());
153
154        assert_eq!(quality.tier, ObservabilityTier::ZeroRedundancy);
155        assert!(!quality.raim_checkable);
156        assert!(quality.covariance_validated);
157    }
158
159    #[test]
160    fn rank_deficient_disables_raim_and_covariance_validation() {
161        let quality = classify(3, 4, 2, 2.0e12, 30.0, true, thresholds());
162
163        assert_eq!(quality.tier, ObservabilityTier::RankDeficient);
164        assert_eq!(quality.rank, 3);
165        assert_eq!(quality.redundancy, 2);
166        assert!(!quality.raim_checkable);
167        assert!(!quality.covariance_validated);
168    }
169
170    #[test]
171    fn weak_when_condition_number_exceeds_cutoff() {
172        let quality = classify(4, 4, 1, 100.0 + 1.0e-9, 2.0, false, thresholds());
173
174        assert_eq!(quality.tier, ObservabilityTier::Weak);
175        assert!(quality.raim_checkable);
176        assert!(quality.covariance_validated);
177    }
178
179    #[test]
180    fn weak_when_gdop_exceeds_cutoff() {
181        let quality = classify(4, 4, 1, 3.0, 10.0 + 1.0e-12, false, thresholds());
182
183        assert_eq!(quality.tier, ObservabilityTier::Weak);
184        assert!(quality.raim_checkable);
185        assert!(quality.covariance_validated);
186    }
187
188    #[test]
189    fn nominal_with_full_rank_and_positive_redundancy() {
190        let quality = classify(4, 4, 2, 20.0, 4.0, false, thresholds());
191
192        assert_eq!(
193            quality,
194            GeometryQuality {
195                tier: ObservabilityTier::Nominal,
196                redundancy: 2,
197                rank: 4,
198                condition_number: 20.0,
199                gdop: 4.0,
200                raim_checkable: true,
201                covariance_validated: true,
202            }
203        );
204    }
205
206    #[test]
207    fn condition_cutoff_boundary_is_strict() {
208        let at_cutoff = classify(4, 4, 1, 100.0, 2.0, false, thresholds());
209        let below_cutoff = classify(4, 4, 1, 100.0 - 1.0e-9, 2.0, false, thresholds());
210        let above_cutoff = classify(4, 4, 1, 100.0 + 1.0e-9, 2.0, false, thresholds());
211
212        assert_eq!(at_cutoff.tier, ObservabilityTier::Nominal);
213        assert_eq!(below_cutoff.tier, ObservabilityTier::Nominal);
214        assert_eq!(above_cutoff.tier, ObservabilityTier::Weak);
215    }
216
217    #[test]
218    fn gdop_cutoff_boundary_is_strict() {
219        let at_cutoff = classify(4, 4, 1, 3.0, 10.0, false, thresholds());
220        let below_cutoff = classify(4, 4, 1, 3.0, 10.0 - 1.0e-12, false, thresholds());
221        let above_cutoff = classify(4, 4, 1, 3.0, 10.0 + 1.0e-12, false, thresholds());
222
223        assert_eq!(at_cutoff.tier, ObservabilityTier::Nominal);
224        assert_eq!(below_cutoff.tier, ObservabilityTier::Nominal);
225        assert_eq!(above_cutoff.tier, ObservabilityTier::Weak);
226    }
227}