Skip to main content

sidereon_core/araim/
reliability.rs

1//! Classical Baarda/Teunissen reliability design.
2//!
3//! Clean-room validation provenance for the tests in this module:
4//! Baarda, W. (1968), "A testing procedure for use in geodetic networks",
5//! Netherlands Geodetic Commission, Publications on Geodesy 2(5).
6//! Teunissen, P. J. G. (2024), "Network Quality Control", TU Delft OPEN.
7
8use crate::araim::ism::Ism;
9use crate::araim::protection::{design_row, gain_matrix_enu, ProtectionModel};
10use crate::astro::math::linear::{invert_symmetric_pd, normal_equations_weighted};
11use crate::astro::math::special::normal_q_inv;
12use crate::quality::{QualityError, DEFAULT_P_FA};
13use crate::validate;
14
15use super::{AraimError, AraimGeometry};
16
17const DEFAULT_BETA: f64 = 0.20;
18const DEFAULT_MIN_REDUNDANCY: f64 = 1.0e-9;
19const REDUNDANCY_TOL: f64 = 1.0e-10;
20
21/// One geometry row for pre-data reliability design.
22#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
23pub struct RangeReliabilityRow {
24    /// Observation identifier echoed into the report.
25    pub id: String,
26    /// Linearized design row for this range observation.
27    pub design_row: Vec<f64>,
28    /// Externally supplied one-sigma range model, meters.
29    pub sigma_m: f64,
30}
31
32/// Options for Baarda/Teunissen reliability design.
33#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
34pub struct ReliabilityOptions {
35    /// Two-sided false-alarm probability for the one-dimensional data-snooping w-test.
36    pub alpha: f64,
37    /// Missed-detection probability for the target bias.
38    pub beta: f64,
39    /// Optional precomputed noncentrality parameter.
40    pub lambda0_override: Option<f64>,
41    /// Redundancy below which an observation is reported as uncheckable.
42    pub min_redundancy: f64,
43}
44
45impl Default for ReliabilityOptions {
46    fn default() -> Self {
47        Self {
48            alpha: DEFAULT_P_FA,
49            beta: DEFAULT_BETA,
50            lambda0_override: None,
51            min_redundancy: DEFAULT_MIN_REDUNDANCY,
52        }
53    }
54}
55
56/// Reliability diagnostics for one observation.
57#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
58pub struct ObservationReliability {
59    /// Observation identifier echoed from the input row.
60    pub id: String,
61    /// Redundancy number `r_i`, the geometry-checked fraction of this observation.
62    pub redundancy: f64,
63    /// Minimal detectable bias in this observation, meters.
64    pub mdb_m: Option<f64>,
65    /// External effect for the just-detectable bias, meters.
66    ///
67    /// [`reliability_araim`] reports local `[east, north, up]`. [`reliability_design`]
68    /// reports the first three state-coordinate entries of the same gain-vector
69    /// imprint, or `None` when the state has fewer than three coordinates.
70    pub external_enu_m: Option<[f64; 3]>,
71    /// Bias-to-noise ratio in state space.
72    pub bias_to_noise: Option<f64>,
73    /// True when redundancy is below the configured reporting floor.
74    pub uncheckable: bool,
75}
76
77/// Aggregate reliability diagnostics for a design.
78#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
79pub struct ReliabilitySummary {
80    /// Number of observations in the design.
81    pub n_obs: usize,
82    /// Number of estimated parameters in the design.
83    pub n_params: usize,
84    /// Algebraic degrees of freedom, `n_obs - n_params`.
85    pub dof: usize,
86    /// Sum of per-observation redundancy numbers.
87    pub sum_redundancy: f64,
88    /// Noncentrality parameter used for MDB calculations.
89    pub lambda0: f64,
90    /// Largest finite MDB in the design, if any observation is checkable.
91    pub max_mdb_m: Option<(String, f64)>,
92    /// Smallest redundancy number and its observation identifier.
93    pub min_redundancy: (String, f64),
94    /// Count of observations reported as uncheckable.
95    pub n_uncheckable: usize,
96}
97
98/// Full reliability design report.
99#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
100pub struct ReliabilityReport {
101    /// Per-observation reliability diagnostics, in input order.
102    pub per_observation: Vec<ObservationReliability>,
103    /// Aggregate design diagnostics.
104    pub summary: ReliabilitySummary,
105}
106
107/// Compute Baarda's one-dimensional data-snooping noncentrality parameter.
108///
109/// The returned value is
110/// `(normal_q_inv(alpha / 2) + normal_q_inv(beta))^2`, with `alpha` treated as a
111/// two-sided false-alarm probability and `beta` as missed-detection probability.
112pub fn wtest_noncentrality(alpha: f64, beta: f64) -> Result<f64, QualityError> {
113    Ok(wtest_noncentrality_components(alpha, beta)?.lambda0)
114}
115
116/// Both Baarda w-test noncentrality forms from a single derivation: the
117/// noncentrality parameter `delta0 = normal_q_inv(alpha / 2) + normal_q_inv(beta)`
118/// and its square `lambda0`. Consumers that report both must take them from
119/// here rather than re-deriving one from the other.
120#[derive(Debug, Clone, Copy, PartialEq)]
121pub struct WtestNoncentralityComponents {
122    /// The noncentrality parameter `delta0`.
123    pub delta0: f64,
124    /// The squared noncentrality `lambda0 = delta0 * delta0`.
125    pub lambda0: f64,
126}
127
128/// Compute [`WtestNoncentralityComponents`] for a two-sided false-alarm
129/// probability `alpha` and a missed-detection probability `beta`.
130pub fn wtest_noncentrality_components(
131    alpha: f64,
132    beta: f64,
133) -> Result<WtestNoncentralityComponents, QualityError> {
134    validate_probability(alpha)?;
135    if !(0.0..1.0).contains(&beta) || !beta.is_finite() {
136        return Err(QualityError::InvalidReliabilityParameter);
137    }
138    let k_alpha = normal_q_inv(0.5 * alpha).ok_or(QualityError::InvalidProbability)?;
139    let k_beta = normal_q_inv(beta).ok_or(QualityError::InvalidReliabilityParameter)?;
140    let delta0 = k_alpha + k_beta;
141    let lambda0 = delta0 * delta0;
142    if lambda0.is_finite() && lambda0 > 0.0 {
143        Ok(WtestNoncentralityComponents { delta0, lambda0 })
144    } else {
145        Err(QualityError::InvalidReliabilityParameter)
146    }
147}
148
149/// Compute internal and external reliability from supplied range geometry.
150///
151/// This is a pre-data design calculation. It consumes only the design matrix
152/// and externally supplied range sigmas, never residuals or measured ranges.
153pub fn reliability_design(
154    rows: &[RangeReliabilityRow],
155    options: &ReliabilityOptions,
156) -> Result<ReliabilityReport, QualityError> {
157    let n_params = validate_reliability_rows(rows)?;
158    let lambda0 = reliability_lambda0(options)?;
159    let q_xx = design_covariance(rows, n_params)?;
160
161    finish_reliability_report(
162        rows,
163        n_params,
164        lambda0,
165        options.min_redundancy,
166        |idx, mdb_m| state_external(rows, &q_xx, idx, mdb_m),
167    )
168}
169
170/// Compute reliability for ARAIM geometry using the same ENU gain matrix as MHSS.
171///
172/// The protection model supplies the range sigmas. The calculation is pre-data
173/// and does not consume residuals or measured pseudoranges.
174pub fn reliability_araim(
175    geometry: &AraimGeometry,
176    model: &dyn ProtectionModel,
177    options: &ReliabilityOptions,
178) -> Result<ReliabilityReport, AraimError> {
179    let rows = araim_rows(geometry, model)?;
180    let lambda0 = reliability_lambda0(options).map_err(map_quality_error)?;
181    let weights = rows
182        .iter()
183        .map(|row| 1.0 / (row.sigma_m * row.sigma_m))
184        .collect::<Vec<_>>();
185    let gain = gain_matrix_enu(geometry, &weights)?;
186    let n_params = geometry_state_len(geometry)?;
187
188    finish_reliability_report(
189        &rows,
190        n_params,
191        lambda0,
192        options.min_redundancy,
193        |idx, mdb_m| {
194            Some([
195                gain.enu_rows[0][idx] * mdb_m,
196                gain.enu_rows[1][idx] * mdb_m,
197                gain.enu_rows[2][idx] * mdb_m,
198            ])
199        },
200    )
201    .map_err(map_quality_error)
202}
203
204impl ProtectionModel for Ism {
205    fn sigma_int_m(&self, row: &super::AraimRow) -> Result<f64, AraimError> {
206        Ok(self.effective_for(row)?.sigma_int_m)
207    }
208
209    fn sigma_acc_m(&self, row: &super::AraimRow) -> Result<f64, AraimError> {
210        Ok(self.effective_for(row)?.sigma_acc_m)
211    }
212}
213
214fn araim_rows(
215    geometry: &AraimGeometry,
216    model: &dyn ProtectionModel,
217) -> Result<Vec<RangeReliabilityRow>, AraimError> {
218    let n_params = geometry_state_len(geometry)?;
219    if geometry.rows.len() < n_params {
220        return Err(AraimError::InsufficientGeometry);
221    }
222    let mut rows = Vec::with_capacity(geometry.rows.len());
223    for row in &geometry.rows {
224        let sigma_int_m = model.sigma_int_m(row)?;
225        let sigma_acc_m = model.sigma_acc_m(row)?;
226        if !valid_positive_finite(sigma_int_m) || !valid_positive_finite(sigma_acc_m) {
227            return Err(AraimError::InvalidIsm);
228        }
229        rows.push(RangeReliabilityRow {
230            id: row.id.to_string(),
231            design_row: design_row(&geometry.clock_systems, row)?,
232            sigma_m: sigma_int_m,
233        });
234    }
235    Ok(rows)
236}
237
238fn reliability_lambda0(options: &ReliabilityOptions) -> Result<f64, QualityError> {
239    validate_reliability_options(options)?;
240    match options.lambda0_override {
241        Some(lambda0) => Ok(lambda0),
242        None => wtest_noncentrality(options.alpha, options.beta),
243    }
244}
245
246fn validate_reliability_options(options: &ReliabilityOptions) -> Result<(), QualityError> {
247    validate_probability(options.alpha)?;
248    if !(0.0..1.0).contains(&options.beta) || !options.beta.is_finite() {
249        return Err(QualityError::InvalidReliabilityParameter);
250    }
251    if let Some(lambda0) = options.lambda0_override {
252        if !lambda0.is_finite() || lambda0 <= 0.0 {
253            return Err(QualityError::InvalidReliabilityParameter);
254        }
255    }
256    if !options.min_redundancy.is_finite() || options.min_redundancy <= 0.0 {
257        return Err(QualityError::InvalidReliabilityParameter);
258    }
259    Ok(())
260}
261
262fn validate_reliability_rows(rows: &[RangeReliabilityRow]) -> Result<usize, QualityError> {
263    let first = rows.first().ok_or(QualityError::InvalidDesign)?;
264    let n_params = first.design_row.len();
265    if n_params == 0 || rows.len() < n_params {
266        return Err(QualityError::InvalidDesign);
267    }
268    for row in rows {
269        if row.design_row.len() != n_params {
270            return Err(QualityError::InvalidDesign);
271        }
272        validate::finite_slice(&row.design_row, "reliability design row")
273            .map_err(|_| QualityError::InvalidDesign)?;
274        validate::finite_positive(row.sigma_m, "reliability sigma")
275            .map_err(|_| QualityError::InvalidWeight)?;
276        let weight = 1.0 / (row.sigma_m * row.sigma_m);
277        if !weight.is_finite() || weight <= 0.0 {
278            return Err(QualityError::InvalidWeight);
279        }
280    }
281    Ok(n_params)
282}
283
284fn design_covariance(
285    rows: &[RangeReliabilityRow],
286    n_params: usize,
287) -> Result<Vec<Vec<f64>>, QualityError> {
288    let (normal, _) = normal_equations_weighted(
289        rows.iter()
290            .map(|row| (row.design_row.as_slice(), 0.0, 1.0 / row.sigma_m)),
291        n_params,
292    )
293    .ok_or(QualityError::InvalidDesign)?;
294    invert_symmetric_pd(&normal).ok_or(QualityError::SingularGeometry)
295}
296
297fn finish_reliability_report<F>(
298    rows: &[RangeReliabilityRow],
299    n_params: usize,
300    lambda0: f64,
301    min_redundancy: f64,
302    mut external: F,
303) -> Result<ReliabilityReport, QualityError>
304where
305    F: FnMut(usize, f64) -> Option<[f64; 3]>,
306{
307    let q_xx = design_covariance(rows, n_params)?;
308    let mut per_observation = Vec::with_capacity(rows.len());
309    let mut sum_redundancy = 0.0;
310    let mut max_mdb_m: Option<(String, f64)> = None;
311    let mut min_pair: Option<(String, f64)> = None;
312    let mut n_uncheckable = 0usize;
313
314    for (idx, row) in rows.iter().enumerate() {
315        let redundancy = redundancy_number(row, &q_xx)?;
316        sum_redundancy += redundancy;
317        update_min_redundancy(&mut min_pair, &row.id, redundancy);
318
319        let uncheckable = redundancy < min_redundancy;
320        let (mdb_m, external_enu_m, bias_to_noise) = if uncheckable {
321            n_uncheckable += 1;
322            (None, None, None)
323        } else {
324            let mdb = row.sigma_m * (lambda0 / redundancy).sqrt();
325            let bnr = (lambda0 * (1.0 - redundancy) / redundancy).sqrt();
326            update_max_mdb(&mut max_mdb_m, &row.id, mdb);
327            (Some(mdb), external(idx, mdb), Some(bnr))
328        };
329
330        per_observation.push(ObservationReliability {
331            id: row.id.clone(),
332            redundancy,
333            mdb_m,
334            external_enu_m,
335            bias_to_noise,
336            uncheckable,
337        });
338    }
339
340    Ok(ReliabilityReport {
341        per_observation,
342        summary: ReliabilitySummary {
343            n_obs: rows.len(),
344            n_params,
345            dof: rows.len().saturating_sub(n_params),
346            sum_redundancy,
347            lambda0,
348            max_mdb_m,
349            min_redundancy: min_pair.unwrap_or_else(|| (String::new(), 0.0)),
350            n_uncheckable,
351        },
352    })
353}
354
355fn redundancy_number(row: &RangeReliabilityRow, q_xx: &[Vec<f64>]) -> Result<f64, QualityError> {
356    let qh = mat_vec(q_xx, &row.design_row);
357    let leverage = dot(&row.design_row, &qh) / (row.sigma_m * row.sigma_m);
358    if !leverage.is_finite() {
359        return Err(QualityError::SingularGeometry);
360    }
361    let raw = 1.0 - leverage;
362    if raw >= 0.0 {
363        Ok(raw.min(1.0))
364    } else if raw.abs() <= REDUNDANCY_TOL {
365        Ok(0.0)
366    } else {
367        Err(QualityError::SingularGeometry)
368    }
369}
370
371fn state_external(
372    rows: &[RangeReliabilityRow],
373    q_xx: &[Vec<f64>],
374    idx: usize,
375    mdb_m: f64,
376) -> Option<[f64; 3]> {
377    if q_xx.len() < 3 {
378        return None;
379    }
380    let row = &rows[idx];
381    let scale = mdb_m / (row.sigma_m * row.sigma_m);
382    let qh = mat_vec(q_xx, &row.design_row);
383    Some([qh[0] * scale, qh[1] * scale, qh[2] * scale])
384}
385
386fn update_min_redundancy(pair: &mut Option<(String, f64)>, id: &str, value: f64) {
387    if match pair.as_ref() {
388        Some((_, current)) => value < *current,
389        None => true,
390    } {
391        *pair = Some((id.to_owned(), value));
392    }
393}
394
395fn update_max_mdb(pair: &mut Option<(String, f64)>, id: &str, value: f64) {
396    if match pair.as_ref() {
397        Some((_, current)) => value > *current,
398        None => true,
399    } {
400        *pair = Some((id.to_owned(), value));
401    }
402}
403
404fn geometry_state_len(geometry: &AraimGeometry) -> Result<usize, AraimError> {
405    if geometry.clock_systems.is_empty() {
406        return Err(AraimError::InsufficientGeometry);
407    }
408    Ok(3 + geometry.clock_systems.len())
409}
410
411fn map_quality_error(err: QualityError) -> AraimError {
412    match err {
413        QualityError::InvalidProbability | QualityError::InvalidReliabilityParameter => {
414            AraimError::InvalidAllocation
415        }
416        QualityError::InvalidWeight => AraimError::InvalidIsm,
417        QualityError::InvalidDesign | QualityError::SingularGeometry => {
418            AraimError::InsufficientGeometry
419        }
420        _ => AraimError::NumericalFailure,
421    }
422}
423
424fn validate_probability(value: f64) -> Result<(), QualityError> {
425    if (0.0..1.0).contains(&value) && value.is_finite() {
426        Ok(())
427    } else {
428        Err(QualityError::InvalidProbability)
429    }
430}
431
432fn valid_positive_finite(value: f64) -> bool {
433    value.is_finite() && value > 0.0
434}
435
436fn mat_vec(a: &[Vec<f64>], x: &[f64]) -> Vec<f64> {
437    a.iter().map(|row| dot(row, x)).collect()
438}
439
440fn dot(a: &[f64], b: &[f64]) -> f64 {
441    a.iter().zip(b).map(|(x, y)| x * y).sum()
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use crate::araim::protection::gain_matrix_enu;
448    use crate::araim::{AraimGeometry, AraimRow};
449    use crate::dop::{ecef_to_enu_rotation, LineOfSight};
450    use crate::frame::Wgs84Geodetic;
451    use crate::id::{GnssSatelliteId, GnssSystem};
452
453    const LAMBDA_BAARDA_4DP: f64 = 17.075;
454
455    #[test]
456    fn algebraic_identities_hold_for_weighted_design() {
457        let rows = algebraic_rows();
458        let options = options_with_lambda(LAMBDA_BAARDA_4DP);
459        let report = reliability_design(&rows, &options).expect("reliability report");
460        let n = rows.len();
461        let u = rows[0].design_row.len();
462        let q_xx = design_covariance(&rows, u).expect("covariance");
463        let r = residual_projector(&rows, &q_xx);
464        let h = design_matrix(&rows);
465        let w = weight_matrix(&rows);
466
467        assert_close(report.summary.sum_redundancy, (n - u) as f64, 2.0e-14);
468        assert_close(trace(&r), (n - u) as f64, 2.0e-14);
469        assert!(inf_norm(&mat_sub(&mat_mul(&r, &r), &r)) < 1.0e-9);
470        assert!(inf_norm(&mat_mul(&r, &h)) < 1.0e-9);
471        assert!(inf_norm(&mat_mul(&mat_mul(&transpose(&r), &w), &h)) < 1.0e-9);
472
473        let q_v = q_v_matrix(&rows, &q_xx);
474        let q_v_w = mat_mul(&q_v, &w);
475        let normal = normal_matrix(&rows);
476        for (idx, obs) in report.per_observation.iter().enumerate() {
477            assert_close(obs.redundancy, q_v_w[idx][idx], 2.0e-14);
478            let mdb = obs.mdb_m.expect("checkable MDB");
479            let dx = full_state_external(&rows[idx], &q_xx, mdb);
480            let bnr_full = quadratic_form(&normal, &dx).sqrt();
481            assert_close(bnr_full, obs.bias_to_noise.expect("checkable BNR"), 2.0e-12);
482        }
483    }
484
485    #[test]
486    fn baarda_wtest_constant_and_monotonicity_are_pinned() {
487        let lambda0 = wtest_noncentrality(1.0e-3, 0.20).expect("canonical lambda");
488        let delta0 = lambda0.sqrt();
489
490        assert_close(delta0, 4.132147965064809, 2.0e-15);
491        assert_close(lambda0, 17.074646805189243, 4.0e-15);
492        assert_eq!((delta0 * 100.0).round() / 100.0, 4.13);
493        assert_eq!((lambda0 * 100.0).round() / 100.0, 17.07);
494
495        let smaller_alpha = wtest_noncentrality(1.0e-4, 0.20).expect("smaller alpha");
496        let smaller_beta = wtest_noncentrality(1.0e-3, 0.10).expect("smaller beta");
497        assert!(smaller_alpha > lambda0);
498        assert!(smaller_beta > lambda0);
499    }
500
501    #[test]
502    fn equal_precision_single_unknown_closed_form_is_exact_to_roundoff() {
503        let lambda0 = wtest_noncentrality(1.0e-3, 0.20).expect("lambda");
504        let sigma_m = 1.0;
505        let rows = equal_precision_rows(2, sigma_m);
506        let report = reliability_design(&rows, &ReliabilityOptions::default()).expect("report");
507
508        for obs in &report.per_observation {
509            assert_close(obs.redundancy, 0.5, 1.0e-15);
510            assert_close(
511                obs.mdb_m.expect("MDB"),
512                sigma_m * (2.0 * lambda0).sqrt(),
513                1.0e-14,
514            );
515            assert_close(obs.bias_to_noise.expect("BNR"), lambda0.sqrt(), 1.0e-14);
516        }
517        assert_eq!(
518            (report.per_observation[0].mdb_m.expect("MDB") * 100.0).round() / 100.0,
519            5.84
520        );
521        assert_eq!(
522            (report.per_observation[0].bias_to_noise.expect("BNR") * 100.0).round() / 100.0,
523            4.13
524        );
525        assert_close(report.summary.sum_redundancy, 1.0, 1.0e-15);
526
527        let report = reliability_design(
528            &equal_precision_rows(1, sigma_m),
529            &ReliabilityOptions::default(),
530        )
531        .expect("one-row report");
532        assert_eq!(report.summary.n_uncheckable, 1);
533        assert!(report.per_observation[0].uncheckable);
534        assert_eq!(report.per_observation[0].mdb_m, None);
535        assert_eq!(report.per_observation[0].bias_to_noise, None);
536        assert_eq!(report.per_observation[0].external_enu_m, None);
537    }
538
539    #[test]
540    fn teunissen_leveling_connection_matches_published_formula() {
541        let n = 4usize;
542        let sigma_h = 2.0;
543        let sigma_h_cap = 3.0;
544        let rows = leveling_connection_rows(n, sigma_h, sigma_h_cap);
545        let report = reliability_design(&rows, &options_with_lambda(LAMBDA_BAARDA_4DP))
546            .expect("leveling report");
547
548        let variance_sum = sigma_h * sigma_h + sigma_h_cap * sigma_h_cap;
549        let common = 1.0 - 1.0 / n as f64;
550        let expected_r_h = sigma_h * sigma_h * common / variance_sum;
551        let expected_r_h_cap = sigma_h_cap * sigma_h_cap * common / variance_sum;
552        let expected_mdb = (LAMBDA_BAARDA_4DP * variance_sum / common).sqrt();
553
554        for obs in &report.per_observation[..n] {
555            assert_close(obs.redundancy, expected_r_h, 2.0e-14);
556            assert_close(obs.mdb_m.expect("MDB"), expected_mdb, 2.0e-13);
557        }
558        for obs in &report.per_observation[n..] {
559            assert_close(obs.redundancy, expected_r_h_cap, 2.0e-14);
560            assert_close(obs.mdb_m.expect("MDB"), expected_mdb, 2.0e-13);
561        }
562        assert_close(report.summary.sum_redundancy, n as f64 - 1.0, 2.0e-13);
563
564        let one = reliability_design(
565            &leveling_connection_rows(1, sigma_h, sigma_h_cap),
566            &options_with_lambda(LAMBDA_BAARDA_4DP),
567        )
568        .expect("one station report");
569        assert_eq!(one.summary.n_uncheckable, 2);
570        assert!(one.per_observation.iter().all(|obs| obs.mdb_m.is_none()));
571    }
572
573    #[test]
574    fn araim_external_reliability_reuses_gain_matrix_columns() {
575        let geometry = simple_araim_geometry();
576        let model = UnitProtectionModel;
577        let options = options_with_lambda(LAMBDA_BAARDA_4DP);
578        let report = reliability_araim(&geometry, &model, &options).expect("ARAIM report");
579        let weights = vec![1.0; geometry.rows.len()];
580        let gain = gain_matrix_enu(&geometry, &weights).expect("gain");
581
582        for (idx, obs) in report.per_observation.iter().enumerate() {
583            let mdb = obs.mdb_m.expect("MDB");
584            let external = obs.external_enu_m.expect("external ENU");
585            for (coord, value) in external.iter().enumerate() {
586                assert_close(*value / mdb, gain.enu_rows[coord][idx], 2.0e-12);
587            }
588        }
589
590        let design_rows = geometry
591            .rows
592            .iter()
593            .map(|row| RangeReliabilityRow {
594                id: row.id.to_string(),
595                design_row: design_row(&geometry.clock_systems, row).expect("design row"),
596                sigma_m: 1.0,
597            })
598            .collect::<Vec<_>>();
599        let design_report = reliability_design(&design_rows, &options).expect("design report");
600        let enu = ecef_to_enu_rotation(geometry.receiver.lat_rad, geometry.receiver.lon_rad);
601        for (idx, obs) in design_report.per_observation.iter().enumerate() {
602            let mdb = obs.mdb_m.expect("MDB");
603            let state = obs.external_enu_m.expect("state external");
604            let rotated = mat3_vec(enu, state);
605            for (coord, value) in rotated.iter().enumerate() {
606                assert_close(*value / mdb, gain.enu_rows[coord][idx], 2.0e-12);
607            }
608        }
609    }
610
611    #[test]
612    fn invalid_inputs_use_quality_error_domains() {
613        let mut rows = equal_precision_rows(2, 1.0);
614        rows[0].sigma_m = 0.0;
615        assert_eq!(
616            reliability_design(&rows, &ReliabilityOptions::default()),
617            Err(QualityError::InvalidWeight)
618        );
619
620        rows = equal_precision_rows(2, 1.0);
621        let mut options = ReliabilityOptions {
622            beta: 0.0,
623            ..ReliabilityOptions::default()
624        };
625        assert_eq!(
626            reliability_design(&rows, &options),
627            Err(QualityError::InvalidReliabilityParameter)
628        );
629        options = ReliabilityOptions {
630            lambda0_override: Some(f64::INFINITY),
631            ..ReliabilityOptions::default()
632        };
633        assert_eq!(
634            reliability_design(&rows, &options),
635            Err(QualityError::InvalidReliabilityParameter)
636        );
637        options = ReliabilityOptions {
638            min_redundancy: 0.0,
639            ..ReliabilityOptions::default()
640        };
641        assert_eq!(
642            reliability_design(&rows, &options),
643            Err(QualityError::InvalidReliabilityParameter)
644        );
645        options = ReliabilityOptions {
646            alpha: 1.0,
647            ..ReliabilityOptions::default()
648        };
649        assert_eq!(
650            reliability_design(&rows, &options),
651            Err(QualityError::InvalidProbability)
652        );
653
654        let singular = vec![
655            RangeReliabilityRow {
656                id: "a".to_owned(),
657                design_row: vec![1.0, 1.0],
658                sigma_m: 1.0,
659            },
660            RangeReliabilityRow {
661                id: "b".to_owned(),
662                design_row: vec![2.0, 2.0],
663                sigma_m: 1.0,
664            },
665        ];
666        assert_eq!(
667            reliability_design(&singular, &ReliabilityOptions::default()),
668            Err(QualityError::SingularGeometry)
669        );
670    }
671
672    struct UnitProtectionModel;
673
674    impl ProtectionModel for UnitProtectionModel {
675        fn sigma_int_m(&self, _row: &AraimRow) -> Result<f64, AraimError> {
676            Ok(1.0)
677        }
678
679        fn sigma_acc_m(&self, _row: &AraimRow) -> Result<f64, AraimError> {
680            Ok(1.0)
681        }
682    }
683
684    fn options_with_lambda(lambda0: f64) -> ReliabilityOptions {
685        ReliabilityOptions {
686            lambda0_override: Some(lambda0),
687            ..ReliabilityOptions::default()
688        }
689    }
690
691    fn algebraic_rows() -> Vec<RangeReliabilityRow> {
692        vec![
693            row("r1", &[1.0, 0.3, -0.2], 0.9),
694            row("r2", &[0.2, 1.1, 0.4], 1.1),
695            row("r3", &[-0.5, 0.7, 1.0], 1.3),
696            row("r4", &[1.2, -0.4, 0.6], 0.8),
697            row("r5", &[-0.7, -0.9, 0.3], 1.5),
698            row("r6", &[0.4, -0.2, -1.1], 1.2),
699        ]
700    }
701
702    fn equal_precision_rows(n: usize, sigma_m: f64) -> Vec<RangeReliabilityRow> {
703        (0..n)
704            .map(|idx| RangeReliabilityRow {
705                id: format!("obs{}", idx + 1),
706                design_row: vec![1.0],
707                sigma_m,
708            })
709            .collect()
710    }
711
712    fn leveling_connection_rows(
713        n: usize,
714        sigma_h: f64,
715        sigma_h_cap: f64,
716    ) -> Vec<RangeReliabilityRow> {
717        let mut rows = Vec::with_capacity(2 * n);
718        for idx in 0..n {
719            let mut design = vec![0.0; n + 1];
720            design[idx] = 1.0;
721            design[n] = 1.0;
722            rows.push(RangeReliabilityRow {
723                id: format!("h{}", idx + 1),
724                design_row: design,
725                sigma_m: sigma_h,
726            });
727        }
728        for idx in 0..n {
729            let mut design = vec![0.0; n + 1];
730            design[idx] = 1.0;
731            rows.push(RangeReliabilityRow {
732                id: format!("H{}", idx + 1),
733                design_row: design,
734                sigma_m: sigma_h_cap,
735            });
736        }
737        rows
738    }
739
740    fn row(id: &str, design_row: &[f64], sigma_m: f64) -> RangeReliabilityRow {
741        RangeReliabilityRow {
742            id: id.to_owned(),
743            design_row: design_row.to_vec(),
744            sigma_m,
745        }
746    }
747
748    fn simple_araim_geometry() -> AraimGeometry {
749        const INV_SQRT_3: f64 = 0.5773502691896258;
750        let receiver = Wgs84Geodetic::new(0.5, 0.2, 0.0).expect("valid receiver");
751        let vectors = [
752            [INV_SQRT_3, INV_SQRT_3, INV_SQRT_3],
753            [INV_SQRT_3, -INV_SQRT_3, -INV_SQRT_3],
754            [-INV_SQRT_3, INV_SQRT_3, -INV_SQRT_3],
755            [-INV_SQRT_3, -INV_SQRT_3, INV_SQRT_3],
756            [0.2, 0.6, 0.7745966692414834],
757        ];
758        let rows = vectors
759            .iter()
760            .enumerate()
761            .map(|(idx, los)| AraimRow {
762                id: GnssSatelliteId::new(GnssSystem::Gps, (idx + 1) as u8)
763                    .expect("valid satellite"),
764                line_of_sight: LineOfSight::new(los[0], los[1], los[2]),
765                system: GnssSystem::Gps,
766                elevation_rad: 0.8,
767            })
768            .collect();
769        AraimGeometry {
770            rows,
771            receiver,
772            clock_systems: vec![GnssSystem::Gps],
773        }
774    }
775
776    fn design_matrix(rows: &[RangeReliabilityRow]) -> Vec<Vec<f64>> {
777        rows.iter().map(|row| row.design_row.clone()).collect()
778    }
779
780    fn weight_matrix(rows: &[RangeReliabilityRow]) -> Vec<Vec<f64>> {
781        let mut w = vec![vec![0.0; rows.len()]; rows.len()];
782        for (idx, row) in rows.iter().enumerate() {
783            w[idx][idx] = 1.0 / (row.sigma_m * row.sigma_m);
784        }
785        w
786    }
787
788    fn residual_projector(rows: &[RangeReliabilityRow], q_xx: &[Vec<f64>]) -> Vec<Vec<f64>> {
789        let h = design_matrix(rows);
790        let h_t = transpose(&h);
791        let w = weight_matrix(rows);
792        let h_q = mat_mul(&h, q_xx);
793        let h_q_h_t_w = mat_mul(&mat_mul(&h_q, &h_t), &w);
794        let mut out = identity(rows.len());
795        for i in 0..rows.len() {
796            for j in 0..rows.len() {
797                out[i][j] -= h_q_h_t_w[i][j];
798            }
799        }
800        out
801    }
802
803    fn q_v_matrix(rows: &[RangeReliabilityRow], q_xx: &[Vec<f64>]) -> Vec<Vec<f64>> {
804        let h = design_matrix(rows);
805        let h_t = transpose(&h);
806        let h_q_h_t = mat_mul(&mat_mul(&h, q_xx), &h_t);
807        let mut q_v = vec![vec![0.0; rows.len()]; rows.len()];
808        for i in 0..rows.len() {
809            q_v[i][i] = rows[i].sigma_m * rows[i].sigma_m;
810            for j in 0..rows.len() {
811                q_v[i][j] -= h_q_h_t[i][j];
812            }
813        }
814        q_v
815    }
816
817    fn normal_matrix(rows: &[RangeReliabilityRow]) -> Vec<Vec<f64>> {
818        let n_params = rows[0].design_row.len();
819        normal_equations_weighted(
820            rows.iter()
821                .map(|row| (row.design_row.as_slice(), 0.0, 1.0 / row.sigma_m)),
822            n_params,
823        )
824        .expect("normal matrix")
825        .0
826    }
827
828    fn full_state_external(row: &RangeReliabilityRow, q_xx: &[Vec<f64>], mdb_m: f64) -> Vec<f64> {
829        let scale = mdb_m / (row.sigma_m * row.sigma_m);
830        mat_vec(q_xx, &row.design_row)
831            .iter()
832            .map(|value| value * scale)
833            .collect()
834    }
835
836    fn identity(n: usize) -> Vec<Vec<f64>> {
837        let mut out = vec![vec![0.0; n]; n];
838        for (idx, row) in out.iter_mut().enumerate() {
839            row[idx] = 1.0;
840        }
841        out
842    }
843
844    fn mat_mul(a: &[Vec<f64>], b: &[Vec<f64>]) -> Vec<Vec<f64>> {
845        let b_t = transpose(b);
846        a.iter()
847            .map(|row| b_t.iter().map(|col| dot(row, col)).collect())
848            .collect()
849    }
850
851    fn mat_sub(a: &[Vec<f64>], b: &[Vec<f64>]) -> Vec<Vec<f64>> {
852        a.iter()
853            .zip(b)
854            .map(|(ra, rb)| ra.iter().zip(rb).map(|(x, y)| x - y).collect())
855            .collect()
856    }
857
858    fn transpose(a: &[Vec<f64>]) -> Vec<Vec<f64>> {
859        let cols = a[0].len();
860        (0..cols)
861            .map(|col| a.iter().map(|row| row[col]).collect())
862            .collect()
863    }
864
865    fn trace(a: &[Vec<f64>]) -> f64 {
866        a.iter().enumerate().map(|(idx, row)| row[idx]).sum()
867    }
868
869    fn inf_norm(a: &[Vec<f64>]) -> f64 {
870        a.iter()
871            .map(|row| row.iter().map(|value| value.abs()).sum::<f64>())
872            .fold(0.0, f64::max)
873    }
874
875    fn quadratic_form(a: &[Vec<f64>], x: &[f64]) -> f64 {
876        dot(x, &mat_vec(a, x))
877    }
878
879    fn mat3_vec(a: [[f64; 3]; 3], x: [f64; 3]) -> [f64; 3] {
880        [
881            a[0][0] * x[0] + a[0][1] * x[1] + a[0][2] * x[2],
882            a[1][0] * x[0] + a[1][1] * x[1] + a[1][2] * x[2],
883            a[2][0] * x[0] + a[2][1] * x[1] + a[2][2] * x[2],
884        ]
885    }
886
887    fn assert_close(actual: f64, expected: f64, tol: f64) {
888        assert!(
889            (actual - expected).abs() <= tol,
890            "actual={actual:.17e} expected={expected:.17e} tol={tol:.17e}"
891        );
892    }
893}