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    validate_probability(alpha)?;
114    if !(0.0..1.0).contains(&beta) || !beta.is_finite() {
115        return Err(QualityError::InvalidReliabilityParameter);
116    }
117    let k_alpha = normal_q_inv(0.5 * alpha).ok_or(QualityError::InvalidProbability)?;
118    let k_beta = normal_q_inv(beta).ok_or(QualityError::InvalidReliabilityParameter)?;
119    let delta0 = k_alpha + k_beta;
120    let lambda0 = delta0 * delta0;
121    if lambda0.is_finite() && lambda0 > 0.0 {
122        Ok(lambda0)
123    } else {
124        Err(QualityError::InvalidReliabilityParameter)
125    }
126}
127
128/// Compute internal and external reliability from supplied range geometry.
129///
130/// This is a pre-data design calculation. It consumes only the design matrix
131/// and externally supplied range sigmas, never residuals or measured ranges.
132pub fn reliability_design(
133    rows: &[RangeReliabilityRow],
134    options: &ReliabilityOptions,
135) -> Result<ReliabilityReport, QualityError> {
136    let n_params = validate_reliability_rows(rows)?;
137    let lambda0 = reliability_lambda0(options)?;
138    let q_xx = design_covariance(rows, n_params)?;
139
140    finish_reliability_report(
141        rows,
142        n_params,
143        lambda0,
144        options.min_redundancy,
145        |idx, mdb_m| state_external(rows, &q_xx, idx, mdb_m),
146    )
147}
148
149/// Compute reliability for ARAIM geometry using the same ENU gain matrix as MHSS.
150///
151/// The protection model supplies the range sigmas. The calculation is pre-data
152/// and does not consume residuals or measured pseudoranges.
153pub fn reliability_araim(
154    geometry: &AraimGeometry,
155    model: &dyn ProtectionModel,
156    options: &ReliabilityOptions,
157) -> Result<ReliabilityReport, AraimError> {
158    let rows = araim_rows(geometry, model)?;
159    let lambda0 = reliability_lambda0(options).map_err(map_quality_error)?;
160    let weights = rows
161        .iter()
162        .map(|row| 1.0 / (row.sigma_m * row.sigma_m))
163        .collect::<Vec<_>>();
164    let gain = gain_matrix_enu(geometry, &weights)?;
165    let n_params = geometry_state_len(geometry)?;
166
167    finish_reliability_report(
168        &rows,
169        n_params,
170        lambda0,
171        options.min_redundancy,
172        |idx, mdb_m| {
173            Some([
174                gain.enu_rows[0][idx] * mdb_m,
175                gain.enu_rows[1][idx] * mdb_m,
176                gain.enu_rows[2][idx] * mdb_m,
177            ])
178        },
179    )
180    .map_err(map_quality_error)
181}
182
183impl ProtectionModel for Ism {
184    fn sigma_int_m(&self, row: &super::AraimRow) -> Result<f64, AraimError> {
185        Ok(self.effective_for(row)?.sigma_int_m)
186    }
187
188    fn sigma_acc_m(&self, row: &super::AraimRow) -> Result<f64, AraimError> {
189        Ok(self.effective_for(row)?.sigma_acc_m)
190    }
191}
192
193fn araim_rows(
194    geometry: &AraimGeometry,
195    model: &dyn ProtectionModel,
196) -> Result<Vec<RangeReliabilityRow>, AraimError> {
197    let n_params = geometry_state_len(geometry)?;
198    if geometry.rows.len() < n_params {
199        return Err(AraimError::InsufficientGeometry);
200    }
201    let mut rows = Vec::with_capacity(geometry.rows.len());
202    for row in &geometry.rows {
203        let sigma_int_m = model.sigma_int_m(row)?;
204        let sigma_acc_m = model.sigma_acc_m(row)?;
205        if !valid_positive_finite(sigma_int_m) || !valid_positive_finite(sigma_acc_m) {
206            return Err(AraimError::InvalidIsm);
207        }
208        rows.push(RangeReliabilityRow {
209            id: row.id.to_string(),
210            design_row: design_row(&geometry.clock_systems, row)?,
211            sigma_m: sigma_int_m,
212        });
213    }
214    Ok(rows)
215}
216
217fn reliability_lambda0(options: &ReliabilityOptions) -> Result<f64, QualityError> {
218    validate_reliability_options(options)?;
219    match options.lambda0_override {
220        Some(lambda0) => Ok(lambda0),
221        None => wtest_noncentrality(options.alpha, options.beta),
222    }
223}
224
225fn validate_reliability_options(options: &ReliabilityOptions) -> Result<(), QualityError> {
226    validate_probability(options.alpha)?;
227    if !(0.0..1.0).contains(&options.beta) || !options.beta.is_finite() {
228        return Err(QualityError::InvalidReliabilityParameter);
229    }
230    if let Some(lambda0) = options.lambda0_override {
231        if !lambda0.is_finite() || lambda0 <= 0.0 {
232            return Err(QualityError::InvalidReliabilityParameter);
233        }
234    }
235    if !options.min_redundancy.is_finite() || options.min_redundancy <= 0.0 {
236        return Err(QualityError::InvalidReliabilityParameter);
237    }
238    Ok(())
239}
240
241fn validate_reliability_rows(rows: &[RangeReliabilityRow]) -> Result<usize, QualityError> {
242    let first = rows.first().ok_or(QualityError::InvalidDesign)?;
243    let n_params = first.design_row.len();
244    if n_params == 0 || rows.len() < n_params {
245        return Err(QualityError::InvalidDesign);
246    }
247    for row in rows {
248        if row.design_row.len() != n_params {
249            return Err(QualityError::InvalidDesign);
250        }
251        validate::finite_slice(&row.design_row, "reliability design row")
252            .map_err(|_| QualityError::InvalidDesign)?;
253        validate::finite_positive(row.sigma_m, "reliability sigma")
254            .map_err(|_| QualityError::InvalidWeight)?;
255        let weight = 1.0 / (row.sigma_m * row.sigma_m);
256        if !weight.is_finite() || weight <= 0.0 {
257            return Err(QualityError::InvalidWeight);
258        }
259    }
260    Ok(n_params)
261}
262
263fn design_covariance(
264    rows: &[RangeReliabilityRow],
265    n_params: usize,
266) -> Result<Vec<Vec<f64>>, QualityError> {
267    let (normal, _) = normal_equations_weighted(
268        rows.iter()
269            .map(|row| (row.design_row.as_slice(), 0.0, 1.0 / row.sigma_m)),
270        n_params,
271    )
272    .ok_or(QualityError::InvalidDesign)?;
273    invert_symmetric_pd(&normal).ok_or(QualityError::SingularGeometry)
274}
275
276fn finish_reliability_report<F>(
277    rows: &[RangeReliabilityRow],
278    n_params: usize,
279    lambda0: f64,
280    min_redundancy: f64,
281    mut external: F,
282) -> Result<ReliabilityReport, QualityError>
283where
284    F: FnMut(usize, f64) -> Option<[f64; 3]>,
285{
286    let q_xx = design_covariance(rows, n_params)?;
287    let mut per_observation = Vec::with_capacity(rows.len());
288    let mut sum_redundancy = 0.0;
289    let mut max_mdb_m: Option<(String, f64)> = None;
290    let mut min_pair: Option<(String, f64)> = None;
291    let mut n_uncheckable = 0usize;
292
293    for (idx, row) in rows.iter().enumerate() {
294        let redundancy = redundancy_number(row, &q_xx)?;
295        sum_redundancy += redundancy;
296        update_min_redundancy(&mut min_pair, &row.id, redundancy);
297
298        let uncheckable = redundancy < min_redundancy;
299        let (mdb_m, external_enu_m, bias_to_noise) = if uncheckable {
300            n_uncheckable += 1;
301            (None, None, None)
302        } else {
303            let mdb = row.sigma_m * (lambda0 / redundancy).sqrt();
304            let bnr = (lambda0 * (1.0 - redundancy) / redundancy).sqrt();
305            update_max_mdb(&mut max_mdb_m, &row.id, mdb);
306            (Some(mdb), external(idx, mdb), Some(bnr))
307        };
308
309        per_observation.push(ObservationReliability {
310            id: row.id.clone(),
311            redundancy,
312            mdb_m,
313            external_enu_m,
314            bias_to_noise,
315            uncheckable,
316        });
317    }
318
319    Ok(ReliabilityReport {
320        per_observation,
321        summary: ReliabilitySummary {
322            n_obs: rows.len(),
323            n_params,
324            dof: rows.len().saturating_sub(n_params),
325            sum_redundancy,
326            lambda0,
327            max_mdb_m,
328            min_redundancy: min_pair.unwrap_or_else(|| (String::new(), 0.0)),
329            n_uncheckable,
330        },
331    })
332}
333
334fn redundancy_number(row: &RangeReliabilityRow, q_xx: &[Vec<f64>]) -> Result<f64, QualityError> {
335    let qh = mat_vec(q_xx, &row.design_row);
336    let leverage = dot(&row.design_row, &qh) / (row.sigma_m * row.sigma_m);
337    if !leverage.is_finite() {
338        return Err(QualityError::SingularGeometry);
339    }
340    let raw = 1.0 - leverage;
341    if raw >= 0.0 {
342        Ok(raw.min(1.0))
343    } else if raw.abs() <= REDUNDANCY_TOL {
344        Ok(0.0)
345    } else {
346        Err(QualityError::SingularGeometry)
347    }
348}
349
350fn state_external(
351    rows: &[RangeReliabilityRow],
352    q_xx: &[Vec<f64>],
353    idx: usize,
354    mdb_m: f64,
355) -> Option<[f64; 3]> {
356    if q_xx.len() < 3 {
357        return None;
358    }
359    let row = &rows[idx];
360    let scale = mdb_m / (row.sigma_m * row.sigma_m);
361    let qh = mat_vec(q_xx, &row.design_row);
362    Some([qh[0] * scale, qh[1] * scale, qh[2] * scale])
363}
364
365fn update_min_redundancy(pair: &mut Option<(String, f64)>, id: &str, value: f64) {
366    if match pair.as_ref() {
367        Some((_, current)) => value < *current,
368        None => true,
369    } {
370        *pair = Some((id.to_owned(), value));
371    }
372}
373
374fn update_max_mdb(pair: &mut Option<(String, f64)>, id: &str, value: f64) {
375    if match pair.as_ref() {
376        Some((_, current)) => value > *current,
377        None => true,
378    } {
379        *pair = Some((id.to_owned(), value));
380    }
381}
382
383fn geometry_state_len(geometry: &AraimGeometry) -> Result<usize, AraimError> {
384    if geometry.clock_systems.is_empty() {
385        return Err(AraimError::InsufficientGeometry);
386    }
387    Ok(3 + geometry.clock_systems.len())
388}
389
390fn map_quality_error(err: QualityError) -> AraimError {
391    match err {
392        QualityError::InvalidProbability | QualityError::InvalidReliabilityParameter => {
393            AraimError::InvalidAllocation
394        }
395        QualityError::InvalidWeight => AraimError::InvalidIsm,
396        QualityError::InvalidDesign | QualityError::SingularGeometry => {
397            AraimError::InsufficientGeometry
398        }
399        _ => AraimError::NumericalFailure,
400    }
401}
402
403fn validate_probability(value: f64) -> Result<(), QualityError> {
404    if (0.0..1.0).contains(&value) && value.is_finite() {
405        Ok(())
406    } else {
407        Err(QualityError::InvalidProbability)
408    }
409}
410
411fn valid_positive_finite(value: f64) -> bool {
412    value.is_finite() && value > 0.0
413}
414
415fn mat_vec(a: &[Vec<f64>], x: &[f64]) -> Vec<f64> {
416    a.iter().map(|row| dot(row, x)).collect()
417}
418
419fn dot(a: &[f64], b: &[f64]) -> f64 {
420    a.iter().zip(b).map(|(x, y)| x * y).sum()
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use crate::araim::protection::gain_matrix_enu;
427    use crate::araim::{AraimGeometry, AraimRow};
428    use crate::dop::{ecef_to_enu_rotation, LineOfSight};
429    use crate::frame::Wgs84Geodetic;
430    use crate::id::{GnssSatelliteId, GnssSystem};
431
432    const LAMBDA_BAARDA_4DP: f64 = 17.075;
433
434    #[test]
435    fn algebraic_identities_hold_for_weighted_design() {
436        let rows = algebraic_rows();
437        let options = options_with_lambda(LAMBDA_BAARDA_4DP);
438        let report = reliability_design(&rows, &options).expect("reliability report");
439        let n = rows.len();
440        let u = rows[0].design_row.len();
441        let q_xx = design_covariance(&rows, u).expect("covariance");
442        let r = residual_projector(&rows, &q_xx);
443        let h = design_matrix(&rows);
444        let w = weight_matrix(&rows);
445
446        assert_close(report.summary.sum_redundancy, (n - u) as f64, 2.0e-14);
447        assert_close(trace(&r), (n - u) as f64, 2.0e-14);
448        assert!(inf_norm(&mat_sub(&mat_mul(&r, &r), &r)) < 1.0e-9);
449        assert!(inf_norm(&mat_mul(&r, &h)) < 1.0e-9);
450        assert!(inf_norm(&mat_mul(&mat_mul(&transpose(&r), &w), &h)) < 1.0e-9);
451
452        let q_v = q_v_matrix(&rows, &q_xx);
453        let q_v_w = mat_mul(&q_v, &w);
454        let normal = normal_matrix(&rows);
455        for (idx, obs) in report.per_observation.iter().enumerate() {
456            assert_close(obs.redundancy, q_v_w[idx][idx], 2.0e-14);
457            let mdb = obs.mdb_m.expect("checkable MDB");
458            let dx = full_state_external(&rows[idx], &q_xx, mdb);
459            let bnr_full = quadratic_form(&normal, &dx).sqrt();
460            assert_close(bnr_full, obs.bias_to_noise.expect("checkable BNR"), 2.0e-12);
461        }
462    }
463
464    #[test]
465    fn baarda_wtest_constant_and_monotonicity_are_pinned() {
466        let lambda0 = wtest_noncentrality(1.0e-3, 0.20).expect("canonical lambda");
467        let delta0 = lambda0.sqrt();
468
469        assert_close(delta0, 4.132147965064809, 2.0e-15);
470        assert_close(lambda0, 17.074646805189243, 4.0e-15);
471        assert_eq!((delta0 * 100.0).round() / 100.0, 4.13);
472        assert_eq!((lambda0 * 100.0).round() / 100.0, 17.07);
473
474        let smaller_alpha = wtest_noncentrality(1.0e-4, 0.20).expect("smaller alpha");
475        let smaller_beta = wtest_noncentrality(1.0e-3, 0.10).expect("smaller beta");
476        assert!(smaller_alpha > lambda0);
477        assert!(smaller_beta > lambda0);
478    }
479
480    #[test]
481    fn equal_precision_single_unknown_closed_form_is_exact_to_roundoff() {
482        let lambda0 = wtest_noncentrality(1.0e-3, 0.20).expect("lambda");
483        let sigma_m = 1.0;
484        let rows = equal_precision_rows(2, sigma_m);
485        let report = reliability_design(&rows, &ReliabilityOptions::default()).expect("report");
486
487        for obs in &report.per_observation {
488            assert_close(obs.redundancy, 0.5, 1.0e-15);
489            assert_close(
490                obs.mdb_m.expect("MDB"),
491                sigma_m * (2.0 * lambda0).sqrt(),
492                1.0e-14,
493            );
494            assert_close(obs.bias_to_noise.expect("BNR"), lambda0.sqrt(), 1.0e-14);
495        }
496        assert_eq!(
497            (report.per_observation[0].mdb_m.expect("MDB") * 100.0).round() / 100.0,
498            5.84
499        );
500        assert_eq!(
501            (report.per_observation[0].bias_to_noise.expect("BNR") * 100.0).round() / 100.0,
502            4.13
503        );
504        assert_close(report.summary.sum_redundancy, 1.0, 1.0e-15);
505
506        let report = reliability_design(
507            &equal_precision_rows(1, sigma_m),
508            &ReliabilityOptions::default(),
509        )
510        .expect("one-row report");
511        assert_eq!(report.summary.n_uncheckable, 1);
512        assert!(report.per_observation[0].uncheckable);
513        assert_eq!(report.per_observation[0].mdb_m, None);
514        assert_eq!(report.per_observation[0].bias_to_noise, None);
515        assert_eq!(report.per_observation[0].external_enu_m, None);
516    }
517
518    #[test]
519    fn teunissen_leveling_connection_matches_published_formula() {
520        let n = 4usize;
521        let sigma_h = 2.0;
522        let sigma_h_cap = 3.0;
523        let rows = leveling_connection_rows(n, sigma_h, sigma_h_cap);
524        let report = reliability_design(&rows, &options_with_lambda(LAMBDA_BAARDA_4DP))
525            .expect("leveling report");
526
527        let variance_sum = sigma_h * sigma_h + sigma_h_cap * sigma_h_cap;
528        let common = 1.0 - 1.0 / n as f64;
529        let expected_r_h = sigma_h * sigma_h * common / variance_sum;
530        let expected_r_h_cap = sigma_h_cap * sigma_h_cap * common / variance_sum;
531        let expected_mdb = (LAMBDA_BAARDA_4DP * variance_sum / common).sqrt();
532
533        for obs in &report.per_observation[..n] {
534            assert_close(obs.redundancy, expected_r_h, 2.0e-14);
535            assert_close(obs.mdb_m.expect("MDB"), expected_mdb, 2.0e-13);
536        }
537        for obs in &report.per_observation[n..] {
538            assert_close(obs.redundancy, expected_r_h_cap, 2.0e-14);
539            assert_close(obs.mdb_m.expect("MDB"), expected_mdb, 2.0e-13);
540        }
541        assert_close(report.summary.sum_redundancy, n as f64 - 1.0, 2.0e-13);
542
543        let one = reliability_design(
544            &leveling_connection_rows(1, sigma_h, sigma_h_cap),
545            &options_with_lambda(LAMBDA_BAARDA_4DP),
546        )
547        .expect("one station report");
548        assert_eq!(one.summary.n_uncheckable, 2);
549        assert!(one.per_observation.iter().all(|obs| obs.mdb_m.is_none()));
550    }
551
552    #[test]
553    fn araim_external_reliability_reuses_gain_matrix_columns() {
554        let geometry = simple_araim_geometry();
555        let model = UnitProtectionModel;
556        let options = options_with_lambda(LAMBDA_BAARDA_4DP);
557        let report = reliability_araim(&geometry, &model, &options).expect("ARAIM report");
558        let weights = vec![1.0; geometry.rows.len()];
559        let gain = gain_matrix_enu(&geometry, &weights).expect("gain");
560
561        for (idx, obs) in report.per_observation.iter().enumerate() {
562            let mdb = obs.mdb_m.expect("MDB");
563            let external = obs.external_enu_m.expect("external ENU");
564            for (coord, value) in external.iter().enumerate() {
565                assert_close(*value / mdb, gain.enu_rows[coord][idx], 2.0e-12);
566            }
567        }
568
569        let design_rows = geometry
570            .rows
571            .iter()
572            .map(|row| RangeReliabilityRow {
573                id: row.id.to_string(),
574                design_row: design_row(&geometry.clock_systems, row).expect("design row"),
575                sigma_m: 1.0,
576            })
577            .collect::<Vec<_>>();
578        let design_report = reliability_design(&design_rows, &options).expect("design report");
579        let enu = ecef_to_enu_rotation(geometry.receiver.lat_rad, geometry.receiver.lon_rad);
580        for (idx, obs) in design_report.per_observation.iter().enumerate() {
581            let mdb = obs.mdb_m.expect("MDB");
582            let state = obs.external_enu_m.expect("state external");
583            let rotated = mat3_vec(enu, state);
584            for (coord, value) in rotated.iter().enumerate() {
585                assert_close(*value / mdb, gain.enu_rows[coord][idx], 2.0e-12);
586            }
587        }
588    }
589
590    #[test]
591    fn invalid_inputs_use_quality_error_domains() {
592        let mut rows = equal_precision_rows(2, 1.0);
593        rows[0].sigma_m = 0.0;
594        assert_eq!(
595            reliability_design(&rows, &ReliabilityOptions::default()),
596            Err(QualityError::InvalidWeight)
597        );
598
599        rows = equal_precision_rows(2, 1.0);
600        let mut options = ReliabilityOptions {
601            beta: 0.0,
602            ..ReliabilityOptions::default()
603        };
604        assert_eq!(
605            reliability_design(&rows, &options),
606            Err(QualityError::InvalidReliabilityParameter)
607        );
608        options = ReliabilityOptions {
609            lambda0_override: Some(f64::INFINITY),
610            ..ReliabilityOptions::default()
611        };
612        assert_eq!(
613            reliability_design(&rows, &options),
614            Err(QualityError::InvalidReliabilityParameter)
615        );
616        options = ReliabilityOptions {
617            min_redundancy: 0.0,
618            ..ReliabilityOptions::default()
619        };
620        assert_eq!(
621            reliability_design(&rows, &options),
622            Err(QualityError::InvalidReliabilityParameter)
623        );
624        options = ReliabilityOptions {
625            alpha: 1.0,
626            ..ReliabilityOptions::default()
627        };
628        assert_eq!(
629            reliability_design(&rows, &options),
630            Err(QualityError::InvalidProbability)
631        );
632
633        let singular = vec![
634            RangeReliabilityRow {
635                id: "a".to_owned(),
636                design_row: vec![1.0, 1.0],
637                sigma_m: 1.0,
638            },
639            RangeReliabilityRow {
640                id: "b".to_owned(),
641                design_row: vec![2.0, 2.0],
642                sigma_m: 1.0,
643            },
644        ];
645        assert_eq!(
646            reliability_design(&singular, &ReliabilityOptions::default()),
647            Err(QualityError::SingularGeometry)
648        );
649    }
650
651    struct UnitProtectionModel;
652
653    impl ProtectionModel for UnitProtectionModel {
654        fn sigma_int_m(&self, _row: &AraimRow) -> Result<f64, AraimError> {
655            Ok(1.0)
656        }
657
658        fn sigma_acc_m(&self, _row: &AraimRow) -> Result<f64, AraimError> {
659            Ok(1.0)
660        }
661    }
662
663    fn options_with_lambda(lambda0: f64) -> ReliabilityOptions {
664        ReliabilityOptions {
665            lambda0_override: Some(lambda0),
666            ..ReliabilityOptions::default()
667        }
668    }
669
670    fn algebraic_rows() -> Vec<RangeReliabilityRow> {
671        vec![
672            row("r1", &[1.0, 0.3, -0.2], 0.9),
673            row("r2", &[0.2, 1.1, 0.4], 1.1),
674            row("r3", &[-0.5, 0.7, 1.0], 1.3),
675            row("r4", &[1.2, -0.4, 0.6], 0.8),
676            row("r5", &[-0.7, -0.9, 0.3], 1.5),
677            row("r6", &[0.4, -0.2, -1.1], 1.2),
678        ]
679    }
680
681    fn equal_precision_rows(n: usize, sigma_m: f64) -> Vec<RangeReliabilityRow> {
682        (0..n)
683            .map(|idx| RangeReliabilityRow {
684                id: format!("obs{}", idx + 1),
685                design_row: vec![1.0],
686                sigma_m,
687            })
688            .collect()
689    }
690
691    fn leveling_connection_rows(
692        n: usize,
693        sigma_h: f64,
694        sigma_h_cap: f64,
695    ) -> Vec<RangeReliabilityRow> {
696        let mut rows = Vec::with_capacity(2 * n);
697        for idx in 0..n {
698            let mut design = vec![0.0; n + 1];
699            design[idx] = 1.0;
700            design[n] = 1.0;
701            rows.push(RangeReliabilityRow {
702                id: format!("h{}", idx + 1),
703                design_row: design,
704                sigma_m: sigma_h,
705            });
706        }
707        for idx in 0..n {
708            let mut design = vec![0.0; n + 1];
709            design[idx] = 1.0;
710            rows.push(RangeReliabilityRow {
711                id: format!("H{}", idx + 1),
712                design_row: design,
713                sigma_m: sigma_h_cap,
714            });
715        }
716        rows
717    }
718
719    fn row(id: &str, design_row: &[f64], sigma_m: f64) -> RangeReliabilityRow {
720        RangeReliabilityRow {
721            id: id.to_owned(),
722            design_row: design_row.to_vec(),
723            sigma_m,
724        }
725    }
726
727    fn simple_araim_geometry() -> AraimGeometry {
728        const INV_SQRT_3: f64 = 0.5773502691896258;
729        let receiver = Wgs84Geodetic::new(0.5, 0.2, 0.0).expect("valid receiver");
730        let vectors = [
731            [INV_SQRT_3, INV_SQRT_3, INV_SQRT_3],
732            [INV_SQRT_3, -INV_SQRT_3, -INV_SQRT_3],
733            [-INV_SQRT_3, INV_SQRT_3, -INV_SQRT_3],
734            [-INV_SQRT_3, -INV_SQRT_3, INV_SQRT_3],
735            [0.2, 0.6, 0.7745966692414834],
736        ];
737        let rows = vectors
738            .iter()
739            .enumerate()
740            .map(|(idx, los)| AraimRow {
741                id: GnssSatelliteId::new(GnssSystem::Gps, (idx + 1) as u8)
742                    .expect("valid satellite"),
743                line_of_sight: LineOfSight::new(los[0], los[1], los[2]),
744                system: GnssSystem::Gps,
745                elevation_rad: 0.8,
746            })
747            .collect();
748        AraimGeometry {
749            rows,
750            receiver,
751            clock_systems: vec![GnssSystem::Gps],
752        }
753    }
754
755    fn design_matrix(rows: &[RangeReliabilityRow]) -> Vec<Vec<f64>> {
756        rows.iter().map(|row| row.design_row.clone()).collect()
757    }
758
759    fn weight_matrix(rows: &[RangeReliabilityRow]) -> Vec<Vec<f64>> {
760        let mut w = vec![vec![0.0; rows.len()]; rows.len()];
761        for (idx, row) in rows.iter().enumerate() {
762            w[idx][idx] = 1.0 / (row.sigma_m * row.sigma_m);
763        }
764        w
765    }
766
767    fn residual_projector(rows: &[RangeReliabilityRow], q_xx: &[Vec<f64>]) -> Vec<Vec<f64>> {
768        let h = design_matrix(rows);
769        let h_t = transpose(&h);
770        let w = weight_matrix(rows);
771        let h_q = mat_mul(&h, q_xx);
772        let h_q_h_t_w = mat_mul(&mat_mul(&h_q, &h_t), &w);
773        let mut out = identity(rows.len());
774        for i in 0..rows.len() {
775            for j in 0..rows.len() {
776                out[i][j] -= h_q_h_t_w[i][j];
777            }
778        }
779        out
780    }
781
782    fn q_v_matrix(rows: &[RangeReliabilityRow], q_xx: &[Vec<f64>]) -> Vec<Vec<f64>> {
783        let h = design_matrix(rows);
784        let h_t = transpose(&h);
785        let h_q_h_t = mat_mul(&mat_mul(&h, q_xx), &h_t);
786        let mut q_v = vec![vec![0.0; rows.len()]; rows.len()];
787        for i in 0..rows.len() {
788            q_v[i][i] = rows[i].sigma_m * rows[i].sigma_m;
789            for j in 0..rows.len() {
790                q_v[i][j] -= h_q_h_t[i][j];
791            }
792        }
793        q_v
794    }
795
796    fn normal_matrix(rows: &[RangeReliabilityRow]) -> Vec<Vec<f64>> {
797        let n_params = rows[0].design_row.len();
798        normal_equations_weighted(
799            rows.iter()
800                .map(|row| (row.design_row.as_slice(), 0.0, 1.0 / row.sigma_m)),
801            n_params,
802        )
803        .expect("normal matrix")
804        .0
805    }
806
807    fn full_state_external(row: &RangeReliabilityRow, q_xx: &[Vec<f64>], mdb_m: f64) -> Vec<f64> {
808        let scale = mdb_m / (row.sigma_m * row.sigma_m);
809        mat_vec(q_xx, &row.design_row)
810            .iter()
811            .map(|value| value * scale)
812            .collect()
813    }
814
815    fn identity(n: usize) -> Vec<Vec<f64>> {
816        let mut out = vec![vec![0.0; n]; n];
817        for (idx, row) in out.iter_mut().enumerate() {
818            row[idx] = 1.0;
819        }
820        out
821    }
822
823    fn mat_mul(a: &[Vec<f64>], b: &[Vec<f64>]) -> Vec<Vec<f64>> {
824        let b_t = transpose(b);
825        a.iter()
826            .map(|row| b_t.iter().map(|col| dot(row, col)).collect())
827            .collect()
828    }
829
830    fn mat_sub(a: &[Vec<f64>], b: &[Vec<f64>]) -> Vec<Vec<f64>> {
831        a.iter()
832            .zip(b)
833            .map(|(ra, rb)| ra.iter().zip(rb).map(|(x, y)| x - y).collect())
834            .collect()
835    }
836
837    fn transpose(a: &[Vec<f64>]) -> Vec<Vec<f64>> {
838        let cols = a[0].len();
839        (0..cols)
840            .map(|col| a.iter().map(|row| row[col]).collect())
841            .collect()
842    }
843
844    fn trace(a: &[Vec<f64>]) -> f64 {
845        a.iter().enumerate().map(|(idx, row)| row[idx]).sum()
846    }
847
848    fn inf_norm(a: &[Vec<f64>]) -> f64 {
849        a.iter()
850            .map(|row| row.iter().map(|value| value.abs()).sum::<f64>())
851            .fold(0.0, f64::max)
852    }
853
854    fn quadratic_form(a: &[Vec<f64>], x: &[f64]) -> f64 {
855        dot(x, &mat_vec(a, x))
856    }
857
858    fn mat3_vec(a: [[f64; 3]; 3], x: [f64; 3]) -> [f64; 3] {
859        [
860            a[0][0] * x[0] + a[0][1] * x[1] + a[0][2] * x[2],
861            a[1][0] * x[0] + a[1][1] * x[1] + a[1][2] * x[2],
862            a[2][0] * x[0] + a[2][1] * x[1] + a[2][2] * x[2],
863        ]
864    }
865
866    fn assert_close(actual: f64, expected: f64, tol: f64) {
867        assert!(
868            (actual - expected).abs() <= tol,
869            "actual={actual:.17e} expected={expected:.17e} tol={tol:.17e}"
870        );
871    }
872}