1use 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#[derive(Debug, Clone, Copy, PartialEq)]
98pub struct ErrorEllipse {
99 pub semi_major_m: f64,
101 pub semi_minor_m: f64,
103 pub orientation_rad: f64,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq)]
109pub struct PercentileRadius {
110 pub probability: f64,
112 pub radius_m: f64,
114 pub approx_m: f64,
116 pub approx_valid: bool,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq)]
122pub struct PositionErrorMetrics {
123 pub ellipse: ErrorEllipse,
125 pub sigma_e_m: f64,
127 pub sigma_n_m: f64,
129 pub sigma_u_m: f64,
131 pub cep_m: PercentileRadius,
133 pub r95_m: PercentileRadius,
135 pub r99_m: PercentileRadius,
137 pub drms_m: f64,
139 pub two_drms_m: f64,
141 pub vep_m: f64,
143 pub sep_m: PercentileRadius,
145 pub mrse_m: f64,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum ErrorMetricsError {
152 NonFinite,
154 NotPositiveSemidefinite,
156 InvalidProbability,
158 Rotation(DopError),
160}
161
162pub 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
192pub 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
203pub fn metrics_from_position_covariance(
205 covariance: &dop::PositionCovariance,
206) -> Result<PositionErrorMetrics, ErrorMetricsError> {
207 metrics_from_enu_covariance_m2(covariance.enu_m2)
208}
209
210pub 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
238pub 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
255pub 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
270pub 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
285pub 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 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}