Skip to main content

sidereon_core/
error_metrics.rs

1//! Position-error metrics from supplied covariance matrices.
2//!
3//! The functions in this module consume a covariance supplied by another solver
4//! and report standard radial accuracy scalars. No covariance estimation is
5//! performed here.
6
7use crate::astro::frames::transforms::geodetic_from_ecef_proj;
8use crate::astro::math::special::erf;
9use crate::dop::{self, DopError};
10use crate::frame::Wgs84Geodetic;
11use crate::integrity::{self, erfc_inv};
12use crate::precise_positioning::KinematicEpochSolution;
13
14const PSD_DIAGONAL_EPS: f64 = 1.0e-15;
15const PSD_MINOR_EPS: f64 = 1.0e-12;
16const SYMMETRY_EPS: f64 = 1.0e-12;
17const DEGENERATE_REL_EPS: f64 = 1.0e-15;
18const CEP_RGCSP_MIN_RATIO: f64 = 0.25;
19const SEP_APPROX_MIN_RATIO: f64 = 0.35;
20const NORMAL_MEDIAN_ABS: f64 = 0.674_490;
21const SEP_ISOTROPIC_MEDIAN: f64 = 1.537_580;
22const GL_INTERVALS: usize = 64;
23
24#[allow(clippy::excessive_precision)]
25const GL64_POSITIVE_NODES: [f64; 32] = [
26    2.43502926634244325e-02,
27    7.29931217877990424e-02,
28    1.21462819296120544e-01,
29    1.69644420423992831e-01,
30    2.17423643740007083e-01,
31    2.64687162208767424e-01,
32    3.11322871990210970e-01,
33    3.57220158337668126e-01,
34    4.02270157963991570e-01,
35    4.46366017253464087e-01,
36    4.89403145707052956e-01,
37    5.31279464019894565e-01,
38    5.71895646202634000e-01,
39    6.11155355172393278e-01,
40    6.48965471254657311e-01,
41    6.85236313054233270e-01,
42    7.19881850171610771e-01,
43    7.52819907260531940e-01,
44    7.83972358943341385e-01,
45    8.13265315122797539e-01,
46    8.40629296252580316e-01,
47    8.65999398154092770e-01,
48    8.89315445995114140e-01,
49    9.10522137078502825e-01,
50    9.29569172131939570e-01,
51    9.46411374858402765e-01,
52    9.61008799652053658e-01,
53    9.73326827789910975e-01,
54    9.83336253884625977e-01,
55    9.91013371476744287e-01,
56    9.96340116771955220e-01,
57    9.99305041735772170e-01,
58];
59
60#[allow(clippy::excessive_precision)]
61const GL64_POSITIVE_WEIGHTS: [f64; 32] = [
62    4.86909570091397237e-02,
63    4.85754674415033935e-02,
64    4.83447622348029404e-02,
65    4.79993885964583034e-02,
66    4.75401657148303222e-02,
67    4.69681828162099788e-02,
68    4.62847965813143539e-02,
69    4.54916279274180727e-02,
70    4.45905581637566079e-02,
71    4.35837245293234157e-02,
72    4.24735151236535352e-02,
73    4.12625632426234581e-02,
74    3.99537411327204536e-02,
75    3.85501531786155635e-02,
76    3.70551285402399844e-02,
77    3.54722132568822401e-02,
78    3.38051618371418630e-02,
79    3.20579283548514254e-02,
80    3.02346570724024884e-02,
81    2.83396726142594486e-02,
82    2.63774697150548978e-02,
83    2.43527025687111306e-02,
84    2.22701738083829967e-02,
85    2.01348231535300216e-02,
86    1.79517157756973571e-02,
87    1.57260304760250269e-02,
88    1.34630478967191179e-02,
89    1.11681394601309738e-02,
90    8.84675982636339009e-03,
91    6.50445796897848854e-03,
92    4.14703326056443250e-03,
93    1.78328072169469699e-03,
94];
95
96/// A horizontal one-sigma error ellipse.
97#[derive(Debug, Clone, Copy, PartialEq)]
98pub struct ErrorEllipse {
99    /// Semi-major axis length, metres.
100    pub semi_major_m: f64,
101    /// Semi-minor axis length, metres.
102    pub semi_minor_m: f64,
103    /// Semi-major-axis orientation in radians, from east toward north.
104    pub orientation_rad: f64,
105}
106
107/// A percentile circle or sphere radius.
108#[derive(Debug, Clone, Copy, PartialEq)]
109pub struct PercentileRadius {
110    /// Probability mass inside the reported radius.
111    pub probability: f64,
112    /// Exact circle or sphere radius, metres.
113    pub radius_m: f64,
114    /// Approximate radius when a named approximation is applicable.
115    pub approx_m: f64,
116    /// True when `approx_m` is inside the approximation's stated ratio range.
117    pub approx_valid: bool,
118}
119
120/// Standard position-error metrics from one ENU covariance.
121#[derive(Debug, Clone, Copy, PartialEq)]
122pub struct PositionErrorMetrics {
123    /// Horizontal one-sigma covariance ellipse.
124    pub ellipse: ErrorEllipse,
125    /// East standard deviation, metres.
126    pub sigma_e_m: f64,
127    /// North standard deviation, metres.
128    pub sigma_n_m: f64,
129    /// Up standard deviation, metres.
130    pub sigma_u_m: f64,
131    /// Horizontal 50 percent circle radius.
132    pub cep_m: PercentileRadius,
133    /// Horizontal 95 percent circle radius.
134    pub r95_m: PercentileRadius,
135    /// Horizontal 99 percent circle radius.
136    pub r99_m: PercentileRadius,
137    /// Distance root mean square, metres.
138    pub drms_m: f64,
139    /// Two times distance root mean square, metres.
140    pub two_drms_m: f64,
141    /// Vertical 50 percent one-dimensional radius, metres.
142    pub vep_m: f64,
143    /// Three-dimensional 50 percent sphere radius, metres.
144    pub sep_m: PercentileRadius,
145    /// Mean radial spherical error, metres.
146    pub mrse_m: f64,
147}
148
149/// Error returned by position-error metric functions.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum ErrorMetricsError {
152    /// At least one numeric input was NaN or infinite.
153    NonFinite,
154    /// The covariance was not positive semidefinite within tolerance.
155    NotPositiveSemidefinite,
156    /// A probability argument was outside `(0, 1)`.
157    InvalidProbability,
158    /// ECEF-to-ENU rotation failed.
159    Rotation(DopError),
160}
161
162/// Compute all standard metrics from an ENU covariance in square metres.
163pub fn metrics_from_enu_covariance_m2(
164    covariance_enu_m2: [[f64; 3]; 3],
165) -> Result<PositionErrorMetrics, ErrorMetricsError> {
166    let covariance = validate_enu_covariance(covariance_enu_m2)?;
167    let ellipse = error_ellipse_from_enu_m2(covariance)?;
168    let lambda_h = horizontal_eigenvalues(covariance)?;
169    let lambda_3d = eigenvalues_symmetric_3x3(covariance)?;
170    let sigma_e_m = covariance[0][0].max(0.0).sqrt();
171    let sigma_n_m = covariance[1][1].max(0.0).sqrt();
172    let sigma_u_m = covariance[2][2].max(0.0).sqrt();
173    let drms_m = (lambda_h[0].max(0.0) + lambda_h[1].max(0.0)).sqrt();
174    let mrse_m = (lambda_3d[0].max(0.0) + lambda_3d[1].max(0.0) + lambda_3d[2].max(0.0)).sqrt();
175
176    Ok(PositionErrorMetrics {
177        ellipse,
178        sigma_e_m,
179        sigma_n_m,
180        sigma_u_m,
181        cep_m: horizontal_radius_from_eigenvalues(lambda_h, 0.5)?,
182        r95_m: horizontal_radius_from_eigenvalues(lambda_h, 0.95)?,
183        r99_m: horizontal_radius_from_eigenvalues(lambda_h, 0.99)?,
184        drms_m,
185        two_drms_m: 2.0 * drms_m,
186        vep_m: NORMAL_MEDIAN_ABS * sigma_u_m,
187        sep_m: spherical_radius_from_eigenvalues(lambda_3d, 0.5)?,
188        mrse_m,
189    })
190}
191
192/// Rotate an ECEF covariance to ENU and compute all standard metrics.
193pub fn metrics_from_ecef_covariance_m2(
194    covariance_ecef_m2: [[f64; 3]; 3],
195    receiver: Wgs84Geodetic,
196) -> Result<PositionErrorMetrics, ErrorMetricsError> {
197    validate_finite_matrix(covariance_ecef_m2)?;
198    let enu = dop::rotate_covariance_ecef_to_enu_m2(covariance_ecef_m2, receiver)
199        .map_err(ErrorMetricsError::Rotation)?;
200    metrics_from_enu_covariance_m2(enu)
201}
202
203/// Compute all metrics from a DOP position covariance without re-rotating it.
204pub fn metrics_from_position_covariance(
205    covariance: &dop::PositionCovariance,
206) -> Result<PositionErrorMetrics, ErrorMetricsError> {
207    metrics_from_enu_covariance_m2(covariance.enu_m2)
208}
209
210/// Compute all metrics from a kinematic PPP epoch solution.
211///
212/// The solution covariance is stored in ECEF square metres. The receiver
213/// position is converted to WGS84 geodetic coordinates for the ENU rotation.
214pub fn metrics_from_kinematic_solution(
215    solution: &KinematicEpochSolution,
216) -> Result<PositionErrorMetrics, ErrorMetricsError> {
217    let [lon_deg, lat_deg, height_m] = geodetic_from_ecef_proj(
218        solution.position_m[0],
219        solution.position_m[1],
220        solution.position_m[2],
221    )
222    .map_err(|_| {
223        ErrorMetricsError::Rotation(DopError::InvalidInput {
224            field: "position_m",
225            reason: "geodetic conversion failed",
226        })
227    })?;
228    let receiver = Wgs84Geodetic::new(lat_deg.to_radians(), lon_deg.to_radians(), height_m)
229        .map_err(|_| {
230            ErrorMetricsError::Rotation(DopError::InvalidInput {
231                field: "position_m",
232                reason: "geodetic conversion failed",
233            })
234        })?;
235    metrics_from_ecef_covariance_m2(solution.position_covariance_m2, receiver)
236}
237
238/// Horizontal one-sigma ellipse from an ENU covariance in square metres.
239pub fn error_ellipse_from_enu_m2(
240    covariance_enu_m2: [[f64; 3]; 3],
241) -> Result<ErrorEllipse, ErrorMetricsError> {
242    let covariance = validate_enu_covariance(covariance_enu_m2)?;
243    let block = [
244        [covariance[0][0], covariance[0][1]],
245        [covariance[1][0], covariance[1][1]],
246    ];
247    let ellipse = dop::error_ellipse_2x2_unit(block).map_err(map_dop_ellipse_error)?;
248    Ok(ErrorEllipse {
249        semi_major_m: ellipse.semi_major,
250        semi_minor_m: ellipse.semi_minor,
251        orientation_rad: ellipse.orientation_rad,
252    })
253}
254
255/// Exact horizontal percentile circle radius from an ENU covariance.
256///
257/// For eigenvalues `a^2` and `b^2`, the circle probability is
258/// `1 - (1 / (2 pi a b)) int exp(-R^2 g(phi) / 2) / g(phi) dphi`, with
259/// `g(phi) = cos(phi)^2 / a^2 + sin(phi)^2 / b^2`. The integral uses the
260/// fixed 64-node Gauss-Legendre rule over one quadrant and four-fold symmetry.
261pub fn horizontal_radius_at(
262    covariance_enu_m2: [[f64; 3]; 3],
263    probability: f64,
264) -> Result<PercentileRadius, ErrorMetricsError> {
265    let covariance = validate_enu_covariance(covariance_enu_m2)?;
266    let eigenvalues = horizontal_eigenvalues(covariance)?;
267    horizontal_radius_from_eigenvalues(eigenvalues, probability)
268}
269
270/// Exact three-dimensional percentile sphere radius from an ENU covariance.
271///
272/// The full-rank case integrates the Gaussian density over a spherical shell
273/// with fixed Gauss-Legendre angular nodes and analytic radial integration.
274/// Rank-one and rank-two covariances reduce to the corresponding 1D or 2D
275/// radial formulas.
276pub fn spherical_radius_at(
277    covariance_enu_m2: [[f64; 3]; 3],
278    probability: f64,
279) -> Result<PercentileRadius, ErrorMetricsError> {
280    let covariance = validate_enu_covariance(covariance_enu_m2)?;
281    let eigenvalues = eigenvalues_symmetric_3x3(covariance)?;
282    spherical_radius_from_eigenvalues(eigenvalues, probability)
283}
284
285/// Vertical one-dimensional percentile radius from an up variance.
286pub fn vertical_radius_at(sigma_u_m2: f64, probability: f64) -> Result<f64, ErrorMetricsError> {
287    validate_probability(probability)?;
288    if !sigma_u_m2.is_finite() {
289        return Err(ErrorMetricsError::NonFinite);
290    }
291    if sigma_u_m2 < -PSD_DIAGONAL_EPS {
292        return Err(ErrorMetricsError::NotPositiveSemidefinite);
293    }
294    let sigma = sigma_u_m2.max(0.0).sqrt();
295    one_dimensional_radius(sigma, probability)
296}
297
298fn horizontal_radius_from_eigenvalues(
299    eigenvalues: [f64; 2],
300    probability: f64,
301) -> Result<PercentileRadius, ErrorMetricsError> {
302    validate_probability(probability)?;
303    let lambda_major = eigenvalues[0].max(0.0);
304    let lambda_minor = eigenvalues[1].max(0.0);
305    if lambda_major == 0.0 {
306        return Ok(percentile(probability, 0.0, 0.0, true));
307    }
308
309    let sigma_major = lambda_major.sqrt();
310    let sigma_minor = lambda_minor.sqrt();
311    let radius_m = if is_degenerate(lambda_major, lambda_minor) {
312        one_dimensional_radius(sigma_major, probability)?
313    } else {
314        bisect_radius(
315            probability,
316            sigma_major * (-2.0 * (1.0 - probability).ln()).sqrt() + 6.0 * sigma_major,
317            |radius| horizontal_probability_radius(lambda_major, lambda_minor, radius),
318        )
319    };
320
321    let (approx_m, approx_valid) = if probability == 0.5 {
322        let ratio = if sigma_major == 0.0 {
323            1.0
324        } else {
325            sigma_minor / sigma_major
326        };
327        (
328            0.6152 * sigma_major + 0.5620 * sigma_minor,
329            (CEP_RGCSP_MIN_RATIO..=1.0).contains(&ratio),
330        )
331    } else {
332        (radius_m, false)
333    };
334    Ok(percentile(probability, radius_m, approx_m, approx_valid))
335}
336
337fn spherical_radius_from_eigenvalues(
338    eigenvalues: [f64; 3],
339    probability: f64,
340) -> Result<PercentileRadius, ErrorMetricsError> {
341    validate_probability(probability)?;
342    let values = [
343        eigenvalues[0].max(0.0),
344        eigenvalues[1].max(0.0),
345        eigenvalues[2].max(0.0),
346    ];
347    let positive = values.iter().filter(|&&value| value > 0.0).count();
348    if positive == 0 {
349        return Ok(percentile(probability, 0.0, 0.0, true));
350    }
351
352    let radius_m = match positive {
353        1 => one_dimensional_radius(values[0].sqrt(), probability)?,
354        2 => horizontal_radius_from_eigenvalues([values[0], values[1]], probability)?.radius_m,
355        _ if nearly_equal(values[0], values[2]) && probability == 0.5 => {
356            SEP_ISOTROPIC_MEDIAN * values[0].sqrt()
357        }
358        _ if nearly_equal(values[0], values[2]) => maxwell_radius(values[0].sqrt(), probability),
359        _ => {
360            let sigma_major = values[0].sqrt();
361            bisect_radius(
362                probability,
363                sigma_major * (-2.0 * (1.0 - probability).ln()).sqrt() + 6.0 * sigma_major,
364                |radius| spherical_probability_radius(values, radius),
365            )
366        }
367    };
368
369    let sigmas = [values[0].sqrt(), values[1].sqrt(), values[2].sqrt()];
370    let ratio = if sigmas[0] == 0.0 {
371        1.0
372    } else {
373        sigmas[2] / sigmas[0]
374    };
375    let approx_m = if probability == 0.5 {
376        0.51 * (sigmas[0] + sigmas[1] + sigmas[2])
377    } else {
378        radius_m
379    };
380    let approx_valid = probability == 0.5 && ratio >= SEP_APPROX_MIN_RATIO;
381    Ok(percentile(probability, radius_m, approx_m, approx_valid))
382}
383
384fn horizontal_probability_radius(lambda_major: f64, lambda_minor: f64, radius: f64) -> f64 {
385    if radius < 0.0 {
386        return 0.0;
387    }
388    if lambda_major == 0.0 {
389        return 1.0;
390    }
391    if is_degenerate(lambda_major, lambda_minor) {
392        let sigma = lambda_major.sqrt();
393        return erf(radius / (core::f64::consts::SQRT_2 * sigma));
394    }
395
396    let sigma_major = lambda_major.sqrt();
397    let sigma_minor = lambda_minor.sqrt();
398    if sigma_minor / sigma_major < 0.1 {
399        return erf(radius / (core::f64::consts::SQRT_2 * sigma_major));
400    }
401    let quarter = integrate_gl64(0.0, core::f64::consts::FRAC_PI_2, |phi| {
402        let cos_phi = phi.cos();
403        let sin_phi = phi.sin();
404        let guarded_minor = lambda_minor.max(lambda_major * DEGENERATE_REL_EPS);
405        let g = cos_phi * cos_phi / lambda_major + sin_phi * sin_phi / guarded_minor;
406        (-0.5 * radius * radius * g).exp() / g
407    });
408    let integral = 4.0 * quarter;
409    let outside = integral / (2.0 * core::f64::consts::PI * sigma_major * sigma_minor);
410    (1.0 - outside).clamp(0.0, 1.0)
411}
412
413fn spherical_probability_radius(eigenvalues: [f64; 3], radius: f64) -> f64 {
414    if radius < 0.0 {
415        return 0.0;
416    }
417    let sigmas = [
418        eigenvalues[0].sqrt(),
419        eigenvalues[1].sqrt(),
420        eigenvalues[2].sqrt(),
421    ];
422    let theta_integral = integrate_gl64(0.0, core::f64::consts::FRAC_PI_2, |theta| {
423        let sin_theta = theta.sin();
424        let cos_theta = theta.cos();
425        integrate_gl64(0.0, core::f64::consts::FRAC_PI_2, |phi| {
426            let cos_phi = phi.cos();
427            let sin_phi = phi.sin();
428            let u0 = sin_theta * cos_phi;
429            let u1 = sin_theta * sin_phi;
430            let u2 = cos_theta;
431            let h = u0 * u0 / eigenvalues[0] + u1 * u1 / eigenvalues[1] + u2 * u2 / eigenvalues[2];
432            radial_integral(radius, h) * sin_theta
433        })
434    });
435    let shell_integral = 8.0 * theta_integral;
436    let norm = (2.0 * core::f64::consts::PI).powf(-1.5) / (sigmas[0] * sigmas[1] * sigmas[2]);
437    (norm * shell_integral).clamp(0.0, 1.0)
438}
439
440fn radial_integral(radius: f64, h: f64) -> f64 {
441    let a = 0.5 * h;
442    let sqrt_a = a.sqrt();
443    let ar2 = a * radius * radius;
444    core::f64::consts::PI.sqrt() * erf(radius * sqrt_a) / (4.0 * a * sqrt_a)
445        - radius * (-ar2).exp() / (2.0 * a)
446}
447
448fn one_dimensional_radius(sigma: f64, probability: f64) -> Result<f64, ErrorMetricsError> {
449    if sigma == 0.0 {
450        return Ok(0.0);
451    }
452    let inv = erfc_inv(1.0 - probability).ok_or(ErrorMetricsError::InvalidProbability)?;
453    Ok(sigma * core::f64::consts::SQRT_2 * inv)
454}
455
456fn maxwell_radius(sigma: f64, probability: f64) -> f64 {
457    if sigma == 0.0 {
458        return 0.0;
459    }
460    bisect_radius(probability, 8.0 * sigma, |radius| {
461        let x = radius / sigma;
462        erf(x * core::f64::consts::FRAC_1_SQRT_2)
463            - (2.0 / core::f64::consts::PI).sqrt() * x * (-0.5 * x * x).exp()
464    })
465}
466
467fn bisect_radius<F>(probability: f64, hi_initial: f64, mut cdf: F) -> f64
468where
469    F: FnMut(f64) -> f64,
470{
471    let mut lo = 0.0;
472    let mut hi = hi_initial;
473    for _ in 0..8 {
474        if cdf(hi) >= probability || !hi.is_finite() {
475            break;
476        }
477        hi *= 2.0;
478    }
479    for _ in 0..60 {
480        let mid = 0.5 * (lo + hi);
481        if cdf(mid) < probability {
482            lo = mid;
483        } else {
484            hi = mid;
485        }
486    }
487    0.5 * (lo + hi)
488}
489
490fn integrate_gl64<F>(a: f64, b: f64, mut f: F) -> f64
491where
492    F: FnMut(f64) -> f64,
493{
494    debug_assert_eq!(GL_INTERVALS, GL64_POSITIVE_NODES.len() * 2);
495    let center = 0.5 * (a + b);
496    let half = 0.5 * (b - a);
497    let mut sum = 0.0;
498    for idx in 0..GL64_POSITIVE_NODES.len() {
499        let node = GL64_POSITIVE_NODES[idx];
500        let weight = GL64_POSITIVE_WEIGHTS[idx];
501        sum += weight * (f(center - half * node) + f(center + half * node));
502    }
503    half * sum
504}
505
506fn horizontal_eigenvalues(covariance: [[f64; 3]; 3]) -> Result<[f64; 2], ErrorMetricsError> {
507    let block = [
508        [covariance[0][0], covariance[0][1]],
509        [covariance[1][0], covariance[1][1]],
510    ];
511    let ellipse = integrity::error_ellipse_2x2_unit(block).map_err(map_integrity_error)?;
512    Ok([ellipse.semi_major.powi(2), ellipse.semi_minor.powi(2)])
513}
514
515fn eigenvalues_symmetric_3x3(covariance: [[f64; 3]; 3]) -> Result<[f64; 3], ErrorMetricsError> {
516    let a00 = covariance[0][0];
517    let a11 = covariance[1][1];
518    let a22 = covariance[2][2];
519    let a01 = 0.5 * (covariance[0][1] + covariance[1][0]);
520    let a02 = 0.5 * (covariance[0][2] + covariance[2][0]);
521    let a12 = 0.5 * (covariance[1][2] + covariance[2][1]);
522    let p1 = a01 * a01 + a02 * a02 + a12 * a12;
523    let mut values = if p1 == 0.0 {
524        [a00, a11, a22]
525    } else {
526        let q = (a00 + a11 + a22) / 3.0;
527        let b00 = a00 - q;
528        let b11 = a11 - q;
529        let b22 = a22 - q;
530        let p2 = b00 * b00 + b11 * b11 + b22 * b22 + 2.0 * p1;
531        let p = (p2 / 6.0).sqrt();
532        let inv_p = 1.0 / p;
533        let b = [
534            [b00 * inv_p, a01 * inv_p, a02 * inv_p],
535            [a01 * inv_p, b11 * inv_p, a12 * inv_p],
536            [a02 * inv_p, a12 * inv_p, b22 * inv_p],
537        ];
538        let r = (det3(b) / 2.0).clamp(-1.0, 1.0);
539        let phi = r.acos() / 3.0;
540        let lambda1 = q + 2.0 * p * phi.cos();
541        let lambda3 = q + 2.0 * p * (phi + 2.0 * core::f64::consts::PI / 3.0).cos();
542        let lambda2 = a00 + a11 + a22 - lambda1 - lambda3;
543        [lambda1, lambda2, lambda3]
544    };
545    values.sort_by(|a, b| b.total_cmp(a));
546    if values[2] < -PSD_MINOR_EPS {
547        return Err(ErrorMetricsError::NotPositiveSemidefinite);
548    }
549    Ok(values)
550}
551
552#[allow(clippy::needless_range_loop)]
553fn validate_enu_covariance(covariance: [[f64; 3]; 3]) -> Result<[[f64; 3]; 3], ErrorMetricsError> {
554    validate_finite_matrix(covariance)?;
555    let scale = covariance_scale(&covariance);
556    let symmetry_tol = SYMMETRY_EPS * scale.max(1.0);
557    for i in 0..3 {
558        if covariance[i][i] < -PSD_DIAGONAL_EPS {
559            return Err(ErrorMetricsError::NotPositiveSemidefinite);
560        }
561        for j in (i + 1)..3 {
562            if (covariance[i][j] - covariance[j][i]).abs() > symmetry_tol {
563                return Err(ErrorMetricsError::NotPositiveSemidefinite);
564            }
565        }
566    }
567    eigenvalues_symmetric_3x3(covariance)?;
568    Ok(covariance)
569}
570
571fn validate_finite_matrix(covariance: [[f64; 3]; 3]) -> Result<(), ErrorMetricsError> {
572    for row in covariance {
573        if row.iter().any(|value| !value.is_finite()) {
574            return Err(ErrorMetricsError::NonFinite);
575        }
576    }
577    Ok(())
578}
579
580fn validate_probability(probability: f64) -> Result<(), ErrorMetricsError> {
581    if probability.is_finite() && (0.0..1.0).contains(&probability) {
582        Ok(())
583    } else {
584        Err(ErrorMetricsError::InvalidProbability)
585    }
586}
587
588fn covariance_scale(covariance: &[[f64; 3]; 3]) -> f64 {
589    covariance
590        .iter()
591        .flatten()
592        .fold(0.0_f64, |scale, value| scale.max(value.abs()))
593}
594
595fn det3(matrix: [[f64; 3]; 3]) -> f64 {
596    matrix[0][0] * (matrix[1][1] * matrix[2][2] - matrix[1][2] * matrix[2][1])
597        - matrix[0][1] * (matrix[1][0] * matrix[2][2] - matrix[1][2] * matrix[2][0])
598        + matrix[0][2] * (matrix[1][0] * matrix[2][1] - matrix[1][1] * matrix[2][0])
599}
600
601fn is_degenerate(lambda_major: f64, lambda_minor: f64) -> bool {
602    lambda_minor <= lambda_major * DEGENERATE_REL_EPS
603}
604
605fn nearly_equal(a: f64, b: f64) -> bool {
606    (a - b).abs() <= (a.abs().max(b.abs()).max(1.0) * 1.0e-14)
607}
608
609fn percentile(
610    probability: f64,
611    radius_m: f64,
612    approx_m: f64,
613    approx_valid: bool,
614) -> PercentileRadius {
615    PercentileRadius {
616        probability,
617        radius_m,
618        approx_m,
619        approx_valid,
620    }
621}
622
623fn map_integrity_error(error: integrity::IntegrityError) -> ErrorMetricsError {
624    match error {
625        integrity::IntegrityError::NonFinite => ErrorMetricsError::NonFinite,
626        integrity::IntegrityError::NotPositiveSemidefinite => {
627            ErrorMetricsError::NotPositiveSemidefinite
628        }
629        integrity::IntegrityError::InvalidProbability { .. } => {
630            ErrorMetricsError::InvalidProbability
631        }
632        integrity::IntegrityError::InvalidInput { .. } | integrity::IntegrityError::Singular => {
633            ErrorMetricsError::NotPositiveSemidefinite
634        }
635    }
636}
637
638fn map_dop_ellipse_error(error: DopError) -> ErrorMetricsError {
639    match error {
640        DopError::InvalidInput {
641            reason: "not finite",
642            ..
643        } => ErrorMetricsError::NonFinite,
644        DopError::InvalidInput { .. } => ErrorMetricsError::NotPositiveSemidefinite,
645        DopError::TooFewSatellites | DopError::Singular => {
646            ErrorMetricsError::NotPositiveSemidefinite
647        }
648    }
649}
650
651#[cfg(test)]
652mod tests {
653    //! Validation references: Rayleigh horizontal radius, Maxwell 3D radius,
654    //! normal one-dimensional radius, and published CEP linear approximations.
655
656    use super::*;
657
658    fn assert_close(actual: f64, expected: f64, tol: f64) {
659        assert!(
660            (actual - expected).abs() <= tol,
661            "actual={actual} expected={expected} diff={}",
662            (actual - expected).abs()
663        );
664    }
665
666    fn assert_rel(actual: f64, expected: f64, rel: f64) {
667        let tol = expected.abs() * rel;
668        assert_close(actual, expected, tol);
669    }
670
671    #[test]
672    fn isotropic_rayleigh_constants_match_closed_form() {
673        let sigma = 2.5;
674        let cov = [
675            [sigma * sigma, 0.0, 0.0],
676            [0.0, sigma * sigma, 0.0],
677            [0.0, 0.0, sigma * sigma],
678        ];
679        let metrics = metrics_from_enu_covariance_m2(cov).expect("metrics");
680        assert_rel(metrics.cep_m.radius_m, 1.177_410 * sigma, 1.0e-6);
681        assert_rel(metrics.r95_m.radius_m, 2.447_747 * sigma, 1.0e-6);
682        assert_rel(metrics.r99_m.radius_m, 3.034_854 * sigma, 1.0e-6);
683        assert_rel(metrics.drms_m, core::f64::consts::SQRT_2 * sigma, 1.0e-6);
684        assert_rel(
685            metrics.two_drms_m,
686            2.0 * core::f64::consts::SQRT_2 * sigma,
687            1.0e-6,
688        );
689        assert_eq!(metrics.ellipse.orientation_rad.to_bits(), 0.0_f64.to_bits());
690    }
691
692    #[test]
693    fn hand_eigen_horizontal_ellipse_matches_closed_form() {
694        let cov = [[4.0, 1.0, 0.0], [1.0, 2.0, 0.0], [0.0, 0.0, 1.0]];
695        let ellipse = error_ellipse_from_enu_m2(cov).expect("ellipse");
696        assert_close(
697            ellipse.semi_major_m,
698            (3.0_f64 + 2.0_f64.sqrt()).sqrt(),
699            1.0e-12,
700        );
701        assert_close(
702            ellipse.semi_minor_m,
703            (3.0_f64 - 2.0_f64.sqrt()).sqrt(),
704            1.0e-12,
705        );
706        assert_close(
707            ellipse.orientation_rad,
708            core::f64::consts::PI / 8.0,
709            1.0e-12,
710        );
711    }
712
713    #[test]
714    fn two_drms_coverage_spans_isotropic_to_elongated_cases() {
715        let isotropic_r = 2.0 * (2.0_f64).sqrt();
716        let isotropic = horizontal_probability_radius(1.0, 1.0, isotropic_r);
717        assert_close(isotropic, 1.0 - (-4.0_f64).exp(), 1.0e-12);
718
719        let elongated_r = 2.0 * (100.0_f64 + 1.0e-4).sqrt();
720        let elongated = horizontal_probability_radius(100.0, 1.0e-4, elongated_r);
721        assert!((0.954..0.955).contains(&elongated), "elongated={elongated}");
722    }
723
724    #[test]
725    fn isotropic_three_dimensional_constants_match_spec_values() {
726        let sigma = 3.0;
727        let cov = [
728            [sigma * sigma, 0.0, 0.0],
729            [0.0, sigma * sigma, 0.0],
730            [0.0, 0.0, sigma * sigma],
731        ];
732        let metrics = metrics_from_enu_covariance_m2(cov).expect("metrics");
733        assert_close(metrics.sep_m.radius_m, 1.537_580 * sigma, 1.0e-6);
734        assert_close(metrics.mrse_m, 1.732_051 * sigma, 1.0e-6);
735        assert_close(metrics.vep_m, 0.674_490 * sigma, 1.0e-6);
736    }
737
738    #[test]
739    fn anisotropic_cep_reports_exact_and_validity_flag() {
740        let cov = [[4.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
741        let cep = horizontal_radius_at(cov, 0.5).expect("cep");
742        assert_close(cep.radius_m, 1.740_834_856_488_325, 1.0e-12);
743        assert!(cep.approx_valid);
744
745        let thin = [[4.0, 0.0, 0.0], [0.0, 0.01, 0.0], [0.0, 0.0, 1.0]];
746        let thin_cep = horizontal_radius_at(thin, 0.5).expect("thin cep");
747        assert!(!thin_cep.approx_valid);
748        assert_close(thin_cep.radius_m, 1.348_980, 1.0e-3);
749    }
750
751    #[test]
752    fn quadrature_recovers_isotropic_rayleigh_closed_form() {
753        let sigma = 4.0;
754        let probability = 0.95_f64;
755        let radius = sigma * (-2.0_f64 * (1.0 - probability).ln()).sqrt();
756        let got = horizontal_probability_radius(sigma * sigma, sigma * sigma, radius);
757        assert_close(got, probability, 1.0e-12);
758    }
759
760    #[test]
761    fn degenerate_and_bad_covariances_do_not_produce_tight_numbers() {
762        let rank1 = [[9.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 1.0]];
763        let cep = horizontal_radius_at(rank1, 0.5).expect("rank one cep");
764        let ellipse = error_ellipse_from_enu_m2(rank1).expect("rank one ellipse");
765        assert_close(cep.radius_m, 2.023_469, 1.0e-6);
766        assert_eq!(ellipse.semi_minor_m.to_bits(), 0.0_f64.to_bits());
767
768        let non_psd = [[1.0, 2.0, 0.0], [2.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
769        assert_eq!(
770            horizontal_radius_at(non_psd, 0.5),
771            Err(ErrorMetricsError::NotPositiveSemidefinite)
772        );
773
774        let huge = [[1.0e12, 0.0, 0.0], [0.0, 1.0e12, 0.0], [0.0, 0.0, 1.0e12]];
775        let huge_metrics = metrics_from_enu_covariance_m2(huge).expect("huge finite");
776        assert!(huge_metrics.cep_m.radius_m.is_finite());
777        assert!(huge_metrics.cep_m.radius_m > 1.0e6);
778
779        let non_finite = [[f64::NAN, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
780        assert_eq!(
781            metrics_from_enu_covariance_m2(non_finite),
782            Err(ErrorMetricsError::NonFinite)
783        );
784    }
785
786    #[test]
787    fn horizontal_ellipse_uses_same_unit_ellipse_source_as_dop() {
788        let cov = [[16.0, 3.0, 0.0], [3.0, 4.0, 0.0], [0.0, 0.0, 9.0]];
789        let metrics_ellipse = error_ellipse_from_enu_m2(cov).expect("metrics ellipse");
790        let dop_ellipse =
791            dop::error_ellipse_2x2_unit([[16.0, 3.0], [3.0, 4.0]]).expect("dop ellipse");
792        assert_eq!(
793            metrics_ellipse.semi_major_m.to_bits(),
794            dop_ellipse.semi_major.to_bits()
795        );
796    }
797}