Skip to main content

sidereon_core/integrity/
mod.rs

1//! Shared covariance and protection-metric primitives.
2//!
3//! This module holds the small numerical kernels used by protection-level and
4//! position-error code: a symmetric 2x2 covariance eigensolve, row gain metrics,
5//! and public special-function entry points.
6
7use crate::astro::math::linear::invert_symmetric_pd;
8pub use crate::astro::math::special::{erfc_inv, normal_q, normal_q_inv};
9use crate::dop::ecef_to_enu_rotation;
10use crate::frame::Wgs84Geodetic;
11
12/// A confidence ellipse from an arbitrary 2x2 covariance block.
13///
14/// The semi-axes carry the square root unit of the covariance entries.
15/// `orientation_rad` is measured from axis 0 toward axis 1.
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub struct ErrorEllipse2 {
18    /// Confidence probability associated with `chi_square_scale`.
19    pub confidence: f64,
20    /// Two-degree-of-freedom chi-square scale applied to the covariance
21    /// eigenvalues.
22    pub chi_square_scale: f64,
23    /// Semi-major axis length.
24    pub semi_major: f64,
25    /// Semi-minor axis length.
26    pub semi_minor: f64,
27    /// Semi-major-axis orientation in radians.
28    pub orientation_rad: f64,
29}
30
31/// Error returned by shared integrity primitives.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum IntegrityError {
34    /// At least one numeric input was NaN or infinite.
35    NonFinite,
36    /// The covariance has a negative eigenvalue outside round-off tolerance.
37    NotPositiveSemidefinite,
38    /// A probability argument was outside `(0, 1)`.
39    InvalidProbability {
40        /// Reason the probability failed validation.
41        reason: &'static str,
42    },
43    /// A design or weight input was malformed.
44    InvalidInput {
45        /// Name of the malformed field.
46        field: &'static str,
47        /// Validation failure reason.
48        reason: &'static str,
49    },
50    /// The weighted design matrix was singular.
51    Singular,
52}
53
54/// Gain rows in local ENU coordinates.
55#[derive(Debug, Clone, PartialEq)]
56pub(crate) struct GainMatrix {
57    pub(crate) enu_rows: [Vec<f64>; 3],
58}
59
60/// Confidence ellipse for a symmetric 2x2 covariance block.
61///
62/// The covariance is symmetrized by averaging the off-diagonal entries. The
63/// eigenvalues are the closed-form symmetric-2x2 solution
64/// `lambda = center +/- sqrt(half_delta^2 + b^2)`, then scaled by the
65/// chi-square contour factor `-2 ln(1 - confidence)`.
66pub fn error_ellipse_2x2(
67    covariance: [[f64; 2]; 2],
68    confidence: f64,
69) -> Result<ErrorEllipse2, IntegrityError> {
70    validate_probability(confidence)?;
71    let scale = -2.0 * (1.0 - confidence).ln();
72    error_ellipse_2x2_scaled(covariance, scale, confidence)
73}
74
75/// One-sigma ellipse for a symmetric 2x2 covariance block.
76///
77/// This uses the same eigensolve as [`error_ellipse_2x2`] with a unit scale.
78/// The stored confidence is the two-degree-of-freedom probability associated
79/// with a unit chi-square contour.
80pub fn error_ellipse_2x2_unit(covariance: [[f64; 2]; 2]) -> Result<ErrorEllipse2, IntegrityError> {
81    error_ellipse_2x2_scaled(covariance, 1.0, 1.0 - (-0.5_f64).exp())
82}
83
84/// Standard deviation for one gain row and per-measurement sigmas.
85pub fn metric_sigma(gain_row: &[f64], sigmas_m: &[f64]) -> f64 {
86    metric_cross(gain_row, gain_row, sigmas_m).sqrt()
87}
88
89/// Covariance cross term for two gain rows and per-measurement sigmas.
90pub fn metric_cross(gain_row_a: &[f64], gain_row_b: &[f64], sigmas_m: &[f64]) -> f64 {
91    gain_row_a
92        .iter()
93        .zip(gain_row_b)
94        .zip(sigmas_m)
95        .map(|((&a, &b), &sigma)| a * b * sigma * sigma)
96        .sum()
97}
98
99pub(crate) fn gain_matrix_enu_from_design_rows(
100    design_rows: &[Vec<f64>],
101    weights: &[f64],
102    receiver: Wgs84Geodetic,
103    n_state: usize,
104) -> Result<GainMatrix, IntegrityError> {
105    if weights.len() != design_rows.len() {
106        return Err(invalid_input("weights", "length must match rows"));
107    }
108    if n_state < 3 {
109        return Err(invalid_input("n_state", "must include position columns"));
110    }
111    if weights.iter().filter(|&&weight| weight > 0.0).count() < n_state {
112        return Err(IntegrityError::Singular);
113    }
114
115    let mut normal = vec![vec![0.0_f64; n_state]; n_state];
116    for (row, &weight) in design_rows.iter().zip(weights) {
117        if row.len() != n_state {
118            return Err(invalid_input("rows", "length must match state dimension"));
119        }
120        if !weight.is_finite() || weight < 0.0 {
121            return Err(invalid_input("weights", "not finite non-negative"));
122        }
123        if row.iter().any(|value| !value.is_finite()) {
124            return Err(invalid_input("rows", "not finite"));
125        }
126        if weight > 0.0 {
127            for i in 0..n_state {
128                for j in 0..n_state {
129                    normal[i][j] += row[i] * weight * row[j];
130                }
131            }
132        }
133    }
134
135    let inverse = invert_symmetric_pd(&normal).ok_or(IntegrityError::Singular)?;
136    let mut ecef_rows = [
137        vec![0.0; design_rows.len()],
138        vec![0.0; design_rows.len()],
139        vec![0.0; design_rows.len()],
140    ];
141    for (measurement_idx, (design, &weight)) in design_rows.iter().zip(weights).enumerate() {
142        if weight == 0.0 {
143            continue;
144        }
145        for state_idx in 0..3 {
146            let mut value = 0.0;
147            for col in 0..n_state {
148                value += inverse[state_idx][col] * design[col] * weight;
149            }
150            ecef_rows[state_idx][measurement_idx] = value;
151        }
152    }
153
154    let r = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
155    let mut enu_rows = [
156        vec![0.0; design_rows.len()],
157        vec![0.0; design_rows.len()],
158        vec![0.0; design_rows.len()],
159    ];
160    for coord in 0..3 {
161        for measurement_idx in 0..design_rows.len() {
162            enu_rows[coord][measurement_idx] = r[coord][0] * ecef_rows[0][measurement_idx]
163                + r[coord][1] * ecef_rows[1][measurement_idx]
164                + r[coord][2] * ecef_rows[2][measurement_idx];
165        }
166    }
167
168    Ok(GainMatrix { enu_rows })
169}
170
171fn error_ellipse_2x2_scaled(
172    covariance: [[f64; 2]; 2],
173    chi_square_scale: f64,
174    confidence: f64,
175) -> Result<ErrorEllipse2, IntegrityError> {
176    for row in covariance {
177        if row.iter().any(|value| !value.is_finite()) {
178            return Err(IntegrityError::NonFinite);
179        }
180    }
181
182    let a = covariance[0][0];
183    let b = 0.5 * (covariance[0][1] + covariance[1][0]);
184    let c = covariance[1][1];
185    let half_delta = 0.5 * (a - c);
186    let center = 0.5 * (a + c);
187    let root = (half_delta * half_delta + b * b).sqrt();
188    let lambda_major = center + root;
189    let lambda_minor = center - root;
190    if !lambda_major.is_finite() || !lambda_minor.is_finite() || lambda_minor < -1.0e-12 {
191        return Err(IntegrityError::NotPositiveSemidefinite);
192    }
193
194    let semi_major = (lambda_major.max(0.0) * chi_square_scale).sqrt();
195    let semi_minor = (lambda_minor.max(0.0) * chi_square_scale).sqrt();
196    let orientation_rad = if root == 0.0 {
197        0.0
198    } else {
199        0.5 * (2.0 * b).atan2(a - c)
200    };
201    Ok(ErrorEllipse2 {
202        confidence,
203        chi_square_scale,
204        semi_major,
205        semi_minor,
206        orientation_rad,
207    })
208}
209
210fn validate_probability(value: f64) -> Result<(), IntegrityError> {
211    if !value.is_finite() {
212        return Err(IntegrityError::InvalidProbability {
213            reason: "not finite",
214        });
215    }
216    if !(0.0..1.0).contains(&value) {
217        return Err(IntegrityError::InvalidProbability {
218            reason: "out of range",
219        });
220    }
221    Ok(())
222}
223
224fn invalid_input(field: &'static str, reason: &'static str) -> IntegrityError {
225    IntegrityError::InvalidInput { field, reason }
226}