1use serde::{Deserialize, Serialize};
87
88use crate::error::AnalysisError;
89use crate::statistics::Share;
90
91pub fn gaussian_scale(level: f64) -> Result<f64, AnalysisError> {
98 check_level(level)?;
99 Ok((-2.0 * (-level).ln_1p()).sqrt())
100}
101
102pub fn prediction_scale(level: f64, count: usize) -> Result<f64, AnalysisError> {
111 check_level(level)?;
112 if count < 3 {
113 return Err(AnalysisError::TooFew {
114 what: "landings for a prediction ellipse",
115 count,
116 minimum: 3,
117 });
118 }
119 let n = count as f64;
121 let power = (-2.0 / (n - 2.0)) * (-level).ln_1p();
122 Ok(((n - 1.0) * (n + 1.0) / n * power.exp_m1()).sqrt())
123}
124
125fn check_level(level: f64) -> Result<(), AnalysisError> {
127 if level > 0.0 && level < 1.0 {
128 Ok(())
129 } else {
130 Err(AnalysisError::Domain {
131 what: "ellipse level",
132 value: level,
133 })
134 }
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
140#[serde(try_from = "CovarianceData")]
141pub struct Covariance {
142 east_m2: f64,
143 north_m2: f64,
144 east_north_m2: f64,
145}
146
147#[derive(Deserialize)]
149#[serde(deny_unknown_fields)]
150struct CovarianceData {
151 east_m2: f64,
152 north_m2: f64,
153 east_north_m2: f64,
154}
155
156impl TryFrom<CovarianceData> for Covariance {
157 type Error = AnalysisError;
158
159 fn try_from(data: CovarianceData) -> Result<Self, AnalysisError> {
160 Self::new(data.east_m2, data.north_m2, data.east_north_m2)
161 }
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
166pub struct PrincipalAxes {
167 pub major_variance_m2: f64,
169 pub minor_variance_m2: f64,
172 pub major_heading_rad: f64,
174}
175
176impl Covariance {
177 pub fn new(east_m2: f64, north_m2: f64, east_north_m2: f64) -> Result<Self, AnalysisError> {
187 for (what, value) in [
188 ("east variance", east_m2),
189 ("north variance", north_m2),
190 ("east-north covariance", east_north_m2),
191 ] {
192 if !value.is_finite() {
193 return Err(AnalysisError::Domain { what, value });
194 }
195 }
196 for (what, value) in [("east variance", east_m2), ("north variance", north_m2)] {
197 if value < 0.0 {
198 return Err(AnalysisError::Domain { what, value });
199 }
200 }
201 if east_north_m2.abs() > (1.0 + 4.0 * f64::EPSILON) * east_m2.sqrt() * north_m2.sqrt() {
203 return Err(AnalysisError::Domain {
204 what: "east-north covariance, against the variances",
205 value: east_north_m2,
206 });
207 }
208 Ok(Self {
209 east_m2,
210 north_m2,
211 east_north_m2,
212 })
213 }
214
215 pub fn east_m2(&self) -> f64 {
217 self.east_m2
218 }
219
220 pub fn north_m2(&self) -> f64 {
222 self.north_m2
223 }
224
225 pub fn east_north_m2(&self) -> f64 {
227 self.east_north_m2
228 }
229
230 pub fn principal_axes(&self) -> PrincipalAxes {
236 let (a, c, b) = (self.east_m2, self.north_m2, self.east_north_m2);
237 let major = (0.5 * a + 0.5 * c) + (0.5 * a - 0.5 * c).hypot(b);
238 let determinant = a.mul_add(c, -(b * b));
239 let minor = if major > 0.0 {
241 (determinant / major).max(0.0).min(major)
242 } else {
243 0.0
244 };
245 let from_east = 0.5 * (2.0 * b).atan2(a - c);
247 let heading = std::f64::consts::FRAC_PI_2 - from_east;
248 PrincipalAxes {
249 major_variance_m2: major,
250 minor_variance_m2: minor,
251 major_heading_rad: if heading >= std::f64::consts::PI {
253 0.0
254 } else {
255 heading
256 },
257 }
258 }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
265#[serde(try_from = "EllipseData")]
266#[non_exhaustive]
267pub struct Ellipse {
268 pub level: f64,
271 pub scale: f64,
273 pub center_east_m: f64,
275 pub center_north_m: f64,
277 pub semi_major_m: f64,
279 pub semi_minor_m: f64,
281 pub major_heading_rad: f64,
283}
284
285#[derive(Deserialize)]
287#[serde(deny_unknown_fields)]
288struct EllipseData {
289 level: f64,
290 scale: f64,
291 #[serde(alias = "centre_east_m")]
292 center_east_m: f64,
293 #[serde(alias = "centre_north_m")]
294 center_north_m: f64,
295 semi_major_m: f64,
296 semi_minor_m: f64,
297 major_heading_rad: f64,
298}
299
300impl TryFrom<EllipseData> for Ellipse {
301 type Error = AnalysisError;
302
303 fn try_from(data: EllipseData) -> Result<Self, AnalysisError> {
304 check_level(data.level)?;
305 for (what, value) in [
306 ("ellipse scale", data.scale),
307 ("ellipse center east", data.center_east_m),
308 ("ellipse center north", data.center_north_m),
309 ("ellipse semi-major axis", data.semi_major_m),
310 ("ellipse semi-minor axis", data.semi_minor_m),
311 ("ellipse heading", data.major_heading_rad),
312 ] {
313 if !value.is_finite() {
314 return Err(AnalysisError::Domain { what, value });
315 }
316 }
317 if data.scale < 0.0 {
318 return Err(AnalysisError::Domain {
319 what: "ellipse scale",
320 value: data.scale,
321 });
322 }
323 if !(0.0..=data.semi_major_m).contains(&data.semi_minor_m) {
324 return Err(AnalysisError::Domain {
325 what: "ellipse semi-minor axis, against zero and the semi-major",
326 value: data.semi_minor_m,
327 });
328 }
329 if !(0.0..std::f64::consts::PI).contains(&data.major_heading_rad) {
330 return Err(AnalysisError::Domain {
331 what: "ellipse heading",
332 value: data.major_heading_rad,
333 });
334 }
335 Ok(Self {
336 level: data.level,
337 scale: data.scale,
338 center_east_m: data.center_east_m,
339 center_north_m: data.center_north_m,
340 semi_major_m: data.semi_major_m,
341 semi_minor_m: data.semi_minor_m,
342 major_heading_rad: data.major_heading_rad,
343 })
344 }
345}
346
347impl Ellipse {
348 pub fn gaussian(
357 center_east_m: f64,
358 center_north_m: f64,
359 covariance: &Covariance,
360 level: f64,
361 ) -> Result<Self, AnalysisError> {
362 for (what, value) in [
363 ("ellipse center east", center_east_m),
364 ("ellipse center north", center_north_m),
365 ] {
366 if !value.is_finite() {
367 return Err(AnalysisError::Domain { what, value });
368 }
369 }
370 let scale = gaussian_scale(level)?;
371 let ellipse = Self::scaled(
372 [center_east_m, center_north_m],
373 &covariance.principal_axes(),
374 level,
375 scale,
376 );
377 if !ellipse.semi_major_m.is_finite() {
378 return Err(AnalysisError::Domain {
379 what: "ellipse semi-major axis",
380 value: ellipse.semi_major_m,
381 });
382 }
383 Ok(ellipse)
384 }
385
386 fn scaled(center: [f64; 2], axes: &PrincipalAxes, level: f64, scale: f64) -> Self {
388 Self {
389 level,
390 scale,
391 center_east_m: center[0],
392 center_north_m: center[1],
393 semi_major_m: scale * axes.major_variance_m2.sqrt(),
394 semi_minor_m: scale * axes.minor_variance_m2.sqrt(),
395 major_heading_rad: axes.major_heading_rad,
396 }
397 }
398
399 pub fn area_m2(&self) -> f64 {
401 std::f64::consts::PI * self.semi_major_m * self.semi_minor_m
402 }
403
404 pub fn contains(&self, east_m: f64, north_m: f64) -> bool {
412 let (east, north) = (east_m - self.center_east_m, north_m - self.center_north_m);
413 let (sin, cos) = self.major_heading_rad.sin_cos();
414 let along = east * sin + north * cos;
416 let across = east * cos - north * sin;
417 let floor =
418 1e-12 * (self.semi_major_m + self.center_east_m.abs() + self.center_north_m.abs());
419 let (a, b) = (self.semi_major_m.max(floor), self.semi_minor_m.max(floor));
420 if a > 0.0 && b > 0.0 {
421 (along / a).powi(2) + (across / b).powi(2) <= 1.0
422 } else {
423 east == 0.0 && north == 0.0
425 }
426 }
427}
428
429#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
432#[serde(try_from = "ScatterData")]
433pub struct Scatter {
434 attempted: usize,
435 sorted: Vec<[f64; 2]>,
436}
437
438#[derive(Deserialize)]
440#[serde(deny_unknown_fields)]
441struct ScatterData {
442 attempted: usize,
443 sorted: Vec<[f64; 2]>,
444}
445
446impl TryFrom<ScatterData> for Scatter {
447 type Error = AnalysisError;
448
449 fn try_from(data: ScatterData) -> Result<Self, AnalysisError> {
450 Self::new(data.sorted, data.attempted)
451 }
452}
453
454impl Scatter {
455 pub const MAX_COORDINATE_M: f64 = 1e9;
458
459 pub fn new(points: Vec<[f64; 2]>, attempted: usize) -> Result<Self, AnalysisError> {
468 if points.len() > attempted {
469 return Err(AnalysisError::Count {
470 what: "points in a scatter, against the samples tried",
471 count: points.len(),
472 limit: attempted,
473 });
474 }
475 if let Some(&bad) = points
476 .iter()
477 .flatten()
478 .find(|v| v.is_nan() || v.abs() > Self::MAX_COORDINATE_M)
479 {
480 return Err(AnalysisError::Domain {
481 what: "point in a scatter",
482 value: bad,
483 });
484 }
485 let mut sorted = points;
486 sorted.sort_by(|p, q| p[0].total_cmp(&q[0]).then(p[1].total_cmp(&q[1])));
487 Ok(Self { attempted, sorted })
488 }
489
490 pub fn attempted(&self) -> usize {
492 self.attempted
493 }
494
495 pub fn count(&self) -> usize {
497 self.sorted.len()
498 }
499
500 pub fn missing(&self) -> usize {
502 self.attempted - self.sorted.len()
503 }
504
505 pub fn points(&self) -> &[[f64; 2]] {
507 &self.sorted
508 }
509
510 pub fn mean(&self) -> Option<[f64; 2]> {
512 let shift = *self.sorted.first()?;
513 let mean = self.shifted_mean(shift);
514 Some([shift[0] + mean[0], shift[1] + mean[1]])
515 }
516
517 fn shifted_mean(&self, shift: [f64; 2]) -> [f64; 2] {
519 let (east, north) = self.sorted.iter().fold((0.0, 0.0), |(east, north), p| {
520 (east + (p[0] - shift[0]), north + (p[1] - shift[1]))
521 });
522 let n = self.sorted.len() as f64;
524 [east / n, north / n]
525 }
526
527 pub fn covariance(&self) -> Option<Covariance> {
530 if self.sorted.len() < 2 {
531 return None;
532 }
533 let shift = *self.sorted.first()?;
534 let mean = self.shifted_mean(shift);
535 let (mut ee, mut nn, mut en) = (0.0, 0.0, 0.0);
536 for p in &self.sorted {
537 let east = (p[0] - shift[0]) - mean[0];
538 let north = (p[1] - shift[1]) - mean[1];
539 ee += east * east;
540 nn += north * north;
541 en += east * north;
542 }
543 let dof = (self.sorted.len() - 1) as f64;
545 let (east_m2, north_m2) = (ee / dof, nn / dof);
546 let bound = east_m2.sqrt() * north_m2.sqrt();
549 Some(Covariance {
550 east_m2,
551 north_m2,
552 east_north_m2: (en / dof).max(-bound).min(bound),
553 })
554 }
555
556 pub fn ellipse(&self, level: f64) -> Result<Option<Ellipse>, AnalysisError> {
563 let scale = gaussian_scale(level)?;
564 Ok(self.scaled(level, scale))
565 }
566
567 pub fn prediction_ellipse(&self, level: f64) -> Result<Option<Ellipse>, AnalysisError> {
575 check_level(level)?;
576 if self.sorted.len() < 3 {
577 return Ok(None);
578 }
579 let scale = prediction_scale(level, self.sorted.len())?;
580 Ok(self.scaled(level, scale))
581 }
582
583 fn scaled(&self, level: f64, scale: f64) -> Option<Ellipse> {
586 let center = self.mean()?;
587 let axes = self.principal_axes()?;
588 Some(Ellipse::scaled(center, &axes, level, scale))
589 }
590
591 pub fn principal_axes(&self) -> Option<PrincipalAxes> {
599 let heading = self.covariance()?.principal_axes().major_heading_rad;
600 let (sin, cos) = heading.sin_cos();
601 let shift = *self.sorted.first()?;
602 let mean = self.shifted_mean(shift);
603 let (mut along_squares, mut across_squares) = (0.0, 0.0);
604 for p in &self.sorted {
605 let east = (p[0] - shift[0]) - mean[0];
606 let north = (p[1] - shift[1]) - mean[1];
607 along_squares += (east * sin + north * cos).powi(2);
608 across_squares += (east * cos - north * sin).powi(2);
609 }
610 let dof = (self.sorted.len() - 1) as f64;
612 let (along, across) = (along_squares / dof, across_squares / dof);
613 Some(if across > along {
616 let turned = heading + std::f64::consts::FRAC_PI_2;
617 PrincipalAxes {
618 major_variance_m2: across,
619 minor_variance_m2: along,
620 major_heading_rad: if turned >= std::f64::consts::PI {
621 turned - std::f64::consts::PI
622 } else {
623 turned
624 },
625 }
626 } else {
627 PrincipalAxes {
628 major_variance_m2: along,
629 minor_variance_m2: across,
630 major_heading_rad: heading,
631 }
632 })
633 }
634
635 pub fn share_inside(&self, ellipse: &Ellipse) -> Option<Share> {
639 if self.attempted == 0 {
640 return None;
641 }
642 let inside = self
643 .sorted
644 .iter()
645 .filter(|p| ellipse.contains(p[0], p[1]))
646 .count();
647 let attempted = self.attempted as f64;
649 Some(Share {
650 low: inside as f64 / attempted,
651 high: (inside + self.missing()) as f64 / attempted,
652 })
653 }
654}
655
656#[cfg(test)]
657mod tests {
658 use std::f64::consts::{FRAC_PI_2, PI};
659
660 use hpr_core::random::SeededRng;
661
662 use super::*;
663
664 fn rotated(major: f64, minor: f64, heading: f64) -> Covariance {
668 let (s, c) = heading.sin_cos();
669 Covariance {
671 east_m2: major * s * s + minor * c * c,
672 north_m2: major * c * c + minor * s * s,
673 east_north_m2: (major - minor) * s * c,
674 }
675 }
676
677 fn close(x: f64, y: f64, tolerance: f64) -> bool {
679 (x - y).abs() <= tolerance * x.abs().max(y.abs()).max(1.0)
680 }
681
682 fn same_axis(x: f64, y: f64, tolerance: f64) -> bool {
684 let d = (x - y).rem_euclid(PI);
685 d <= tolerance || PI - d <= tolerance
686 }
687
688 #[test]
689 fn the_scale_of_a_level_is_the_chi_square_quantile() {
690 for (level, table) in [(0.9, 4.605), (0.95, 5.991), (0.99, 9.210)] {
693 let k = gaussian_scale(level).unwrap();
694 assert!((k * k - table).abs() <= 5e-4, "{level}: {}", k * k);
695 }
696 for (level, closed) in [
699 (0.5, 1.386_294_361),
700 (0.9, 4.605_170_186),
701 (0.95, 5.991_464_547),
702 (0.99, 9.210_340_372),
703 ] {
704 let k = gaussian_scale(level).unwrap();
705 assert!((k * k - closed).abs() < 1e-9, "{level}: {}", k * k);
706 }
707 let k = gaussian_scale(-(-1.0_f64).exp_m1()).unwrap();
709 assert!((k * k - 2.0).abs() < 4.0 * f64::EPSILON, "{}", k * k);
710 }
711
712 #[test]
713 fn principal_axes_of_rotated_covariances() {
714 for &(major, minor) in &[(4.0, 1.0), (2500.0, 900.0), (1.0, 0.999), (9.0, 0.0)] {
716 for k in 0..24 {
717 let heading = f64::from(k) * PI / 24.0 + 0.013;
718 let axes = rotated(major, minor, heading).principal_axes();
719 assert!(close(axes.major_variance_m2, major, 1e-14), "{axes:?}");
720 assert!(
721 (axes.minor_variance_m2 - minor).abs() <= 1e-14 * major,
722 "{axes:?}"
723 );
724 assert!((0.0..PI).contains(&axes.major_heading_rad), "{axes:?}");
725 let tolerance = 1e-14 * major / (major - minor);
728 assert!(
729 same_axis(axes.major_heading_rad, heading, tolerance),
730 "{heading}: {axes:?}"
731 );
732 }
733 }
734 let east = Covariance::new(4.0, 1.0, 0.0).unwrap().principal_axes();
736 assert_eq!(
737 (
738 east.major_variance_m2,
739 east.minor_variance_m2,
740 east.major_heading_rad
741 ),
742 (4.0, 1.0, FRAC_PI_2)
743 );
744 let north = Covariance::new(1.0, 4.0, 0.0).unwrap().principal_axes();
745 assert_eq!(
746 (
747 north.major_variance_m2,
748 north.minor_variance_m2,
749 north.major_heading_rad
750 ),
751 (4.0, 1.0, 0.0)
752 );
753 let circle = Covariance::new(2.0, 2.0, 0.0).unwrap().principal_axes();
754 assert_eq!(
755 (
756 circle.major_variance_m2,
757 circle.minor_variance_m2,
758 circle.major_heading_rad
759 ),
760 (2.0, 2.0, FRAC_PI_2)
761 );
762 let diagonal = Covariance::new(2.0, 2.0, 1.0).unwrap().principal_axes();
764 assert_eq!(diagonal.major_heading_rad, FRAC_PI_2 / 2.0);
765 assert_eq!(
766 (diagonal.major_variance_m2, diagonal.minor_variance_m2),
767 (3.0, 1.0)
768 );
769 }
770
771 fn probability_inside(sigma: &Covariance, ellipse: &Ellipse) -> f64 {
781 let (a, c, b) = (sigma.east_m2, sigma.north_m2, sigma.east_north_m2);
782 let det = a * c - b * b;
783 let (inv_ee, inv_nn, inv_en) = (c / det, a / det, -b / det);
784 let (sin, cos) = ellipse.major_heading_rad.sin_cos();
785 let steps = 4096;
786 let mut sum = 0.0;
787 for i in 0..steps {
788 let phi = 2.0 * PI * f64::from(i) / f64::from(steps);
789 let (east, north) = (phi.cos(), phi.sin());
790 let q = inv_ee * east * east + 2.0 * inv_en * east * north + inv_nn * north * north;
791 let along = east * sin + north * cos;
792 let across = east * cos - north * sin;
793 let edge_squared = 1.0
794 / ((along / ellipse.semi_major_m).powi(2)
795 + (across / ellipse.semi_minor_m).powi(2));
796 sum += -(-0.5 * edge_squared * q).exp_m1() / q;
797 }
798 sum * (2.0 * PI / f64::from(steps)) / (2.0 * PI * det.sqrt())
799 }
800
801 #[test]
802 fn a_gaussian_ellipse_holds_its_level() {
803 for &(major, minor, heading) in &[
804 (1.0, 1.0, 0.0),
805 (400.0, 100.0, 0.3),
806 (40_000.0, 900.0, 2.0),
807 (25.0, 24.0, 1.1),
808 ] {
809 let sigma = rotated(major, minor, heading);
810 for level in [0.5, 0.9, 0.95, 0.99] {
811 let ellipse = Ellipse::gaussian(0.0, 0.0, &sigma, level).unwrap();
812 let p = probability_inside(&sigma, &ellipse);
813 assert!((p - level).abs() < 1e-12, "{level}: {p} ({ellipse:?})");
814 let k = gaussian_scale(level).unwrap();
815 assert!(close(ellipse.semi_major_m, k * major.sqrt(), 1e-14));
816 assert!(close(ellipse.semi_minor_m, k * minor.sqrt(), 1e-14));
817 assert!(close(
818 ellipse.area_m2(),
819 PI * k * k * (major * minor).sqrt(),
820 1e-14
821 ));
822 }
823 }
824 let sigma = rotated(40_000.0, 900.0, 2.0);
827 let mut turned = Ellipse::gaussian(0.0, 0.0, &sigma, 0.95).unwrap();
828 turned.major_heading_rad += 0.1;
829 let p = probability_inside(&sigma, &turned);
830 assert!((p - 0.9059).abs() < 1e-4, "{p}");
831 }
832
833 #[test]
834 fn a_sample_covariance_by_hand() {
835 let (s1, s2) = (30.0, 10.0);
838 let heading = FRAC_PI_2 / 2.0;
839 let (sin, cos) = heading.sin_cos();
840 let (u, v) = ([sin, cos], [cos, -sin]);
841 let center = [100.0, -50.0];
842 let points = [(s1, u), (-s1, u), (s2, v), (-s2, v)]
843 .iter()
844 .map(|&(s, d)| [center[0] + s * d[0], center[1] + s * d[1]])
845 .collect();
846 let scatter = Scatter::new(points, 4).unwrap();
847 let mean = scatter.mean().unwrap();
848 assert!(
849 close(mean[0], 100.0, 1e-15) && close(mean[1], -50.0, 1e-15),
850 "{mean:?}"
851 );
852 let axes = scatter.covariance().unwrap().principal_axes();
853 assert!(
854 close(axes.major_variance_m2, 2.0 * s1 * s1 / 3.0, 1e-14),
855 "{axes:?}"
856 );
857 assert!(
858 close(axes.minor_variance_m2, 2.0 * s2 * s2 / 3.0, 1e-13),
859 "{axes:?}"
860 );
861 assert!(
862 same_axis(axes.major_heading_rad, heading, 1e-14),
863 "{axes:?}"
864 );
865 let normal = scatter.ellipse(0.95).unwrap().unwrap();
867 let prediction = scatter.prediction_ellipse(0.95).unwrap().unwrap();
868 assert!(prediction.semi_major_m > normal.semi_major_m);
869 assert_eq!(prediction.scale, prediction_scale(0.95, 4).unwrap());
870 }
871
872 fn normal_points(
875 rng: &mut SeededRng,
876 center: [f64; 2],
877 sigma: &Covariance,
878 count: usize,
879 ) -> Vec<[f64; 2]> {
880 let l11 = sigma.east_m2.sqrt();
881 let l21 = sigma.east_north_m2 / l11;
882 let l22 = (sigma.north_m2 - l21 * l21).sqrt();
883 (0..count)
884 .map(|_| {
885 let (z1, z2) = (rng.standard_normal(), rng.standard_normal());
886 [center[0] + l11 * z1, center[1] + l21 * z1 + l22 * z2]
887 })
888 .collect()
889 }
890
891 #[test]
892 fn a_sampled_gaussian_gives_back_its_ellipse() {
893 let n = 100_000;
896 let sigma = rotated(250_000.0, 40_000.0, 1.2);
897 let center = [600.0, -150.0];
898 let mut rng = SeededRng::seed_from_u64(61);
899 let scatter = Scatter::new(normal_points(&mut rng, center, &sigma, n), n).unwrap();
900 let estimate = scatter.covariance().unwrap();
901 let dof = (n - 1) as f64;
902 let error = |sij: f64, sii: f64, sjj: f64| ((sij * sij + sii * sjj) / dof).sqrt();
904 let (a, c, b) = (sigma.east_m2, sigma.north_m2, sigma.east_north_m2);
905 assert!(
906 (estimate.east_m2 - a).abs() < 5.0 * error(a, a, a),
907 "{estimate:?}"
908 );
909 assert!(
910 (estimate.north_m2 - c).abs() < 5.0 * error(c, c, c),
911 "{estimate:?}"
912 );
913 assert!(
914 (estimate.east_north_m2 - b).abs() < 5.0 * error(b, a, c),
915 "{estimate:?}"
916 );
917 let mean = scatter.mean().unwrap();
918 assert!(
919 (mean[0] - center[0]).abs() < 5.0 * (a / n as f64).sqrt(),
920 "{mean:?}"
921 );
922 assert!(
923 (mean[1] - center[1]).abs() < 5.0 * (c / n as f64).sqrt(),
924 "{mean:?}"
925 );
926 for level in [0.5, 0.9, 0.95, 0.99] {
927 let binomial = (level * (1.0 - level) / n as f64).sqrt();
929 let own = scatter.ellipse(level).unwrap().unwrap();
930 let share = scatter.share_inside(&own).unwrap();
931 assert_eq!(share.low, share.high);
932 assert!(
933 (share.low - level).abs() < 5.0 * binomial,
934 "{level}: {share:?}"
935 );
936 let truth = Ellipse::gaussian(center[0], center[1], &sigma, level).unwrap();
937 let share = scatter.share_inside(&truth).unwrap();
938 assert!(
939 (share.low - level).abs() < 5.0 * binomial,
940 "{level}: {share:?}"
941 );
942 }
943 }
944
945 #[test]
946 fn a_prediction_ellipse_holds_a_new_flight_at_its_level() {
947 let sigma = rotated(900.0, 100.0, 0.7);
951 let level = 0.9;
952 let trials = 20_000;
953 let binomial = (level * (1.0 - level) / f64::from(trials)).sqrt();
954 for count in [3, 5, 20] {
955 let mut rng = SeededRng::for_stream(62, &[count as u64]);
956 let (mut predicted, mut normal) = (0_u32, 0_u32);
957 for _ in 0..trials {
958 let mut points = normal_points(&mut rng, [0.0, 0.0], &sigma, count + 1);
959 let new = points.pop().unwrap();
960 let scatter = Scatter::new(points, count).unwrap();
961 let wide = scatter.prediction_ellipse(level).unwrap().unwrap();
962 predicted += u32::from(wide.contains(new[0], new[1]));
963 let narrow = scatter.ellipse(level).unwrap().unwrap();
964 normal += u32::from(narrow.contains(new[0], new[1]));
965 }
966 let share = f64::from(predicted) / f64::from(trials);
967 assert!((share - level).abs() < 5.0 * binomial, "{count}: {share}");
968 let short = f64::from(normal) / f64::from(trials);
969 assert!(short < level - 10.0 * binomial, "{count}: {short}");
970 }
971 }
972
973 #[test]
974 fn the_prediction_scale_by_hand_and_in_the_limit() {
975 let k = prediction_scale(0.95, 5).unwrap();
978 let f = 1.5 * (0.05_f64.powf(-2.0 / 3.0) - 1.0);
979 assert!((f - 9.552_094).abs() < 1e-6, "{f}");
980 assert!(
981 close(k * k, 2.0 * 6.0 * 4.0 / (5.0 * 3.0) * f, 1e-14),
982 "{}",
983 k * k
984 );
985 let normal = gaussian_scale(0.95).unwrap();
987 let mut last = f64::INFINITY;
988 for count in [3, 10, 100, 1000, 10_000, 1_000_000] {
989 let k = prediction_scale(0.95, count).unwrap();
990 assert!(k < last && k > normal, "{count}: {k}");
991 last = k;
992 }
993 assert!(close(last, normal, 1e-5), "{last}");
995 let at_200 = prediction_scale(0.95, 200).unwrap().powi(2) / (normal * normal);
996 assert!((at_200 - 1.025_513).abs() < 1e-6, "{at_200}");
998 }
999
1000 #[test]
1001 fn a_narrow_spread_keeps_its_width() {
1002 let n = 10_000;
1008 let (sin, cos) = 1.0_f64.sin_cos();
1009 let mut rng = SeededRng::seed_from_u64(64);
1010 let points = (0..n)
1011 .map(|_| {
1012 let (along, across) = (1e2 * rng.standard_normal(), 1e-6 * rng.standard_normal());
1013 [
1014 300.0 + along * sin + across * cos,
1015 40.0 + along * cos - across * sin,
1016 ]
1017 })
1018 .collect();
1019 let scatter = Scatter::new(points, n).unwrap();
1020 let axes = scatter.principal_axes().unwrap();
1021 let error = |s: f64| s * (2.0 / (n - 1) as f64).sqrt();
1022 assert!(
1023 (axes.minor_variance_m2 - 1e-12).abs() < 5.0 * error(1e-12),
1024 "{axes:?}"
1025 );
1026 for level in [0.5, 0.95] {
1027 let binomial = (level * (1.0 - level) / n as f64).sqrt();
1028 let ellipse = scatter.ellipse(level).unwrap().unwrap();
1029 let share = scatter.share_inside(&ellipse).unwrap();
1030 assert!(
1031 (share.low - level).abs() < 5.0 * binomial,
1032 "{level}: {share:?}"
1033 );
1034 }
1035 }
1036
1037 #[test]
1038 fn two_landings_and_a_slanted_line() {
1039 let mut rng = SeededRng::seed_from_u64(65);
1042 for _ in 0..1000 {
1043 let mut point = || {
1044 [
1045 1000.0 * rng.uniform() - 500.0,
1046 1000.0 * rng.uniform() - 500.0,
1047 ]
1048 };
1049 let points = vec![point(), point()];
1050 let scatter = Scatter::new(points.clone(), 2).unwrap();
1051 let covariance = scatter.covariance().unwrap();
1052 let again = Covariance::new(
1053 covariance.east_m2(),
1054 covariance.north_m2(),
1055 covariance.east_north_m2(),
1056 )
1057 .unwrap();
1058 assert_eq!(again, covariance);
1059 let json = serde_json::to_string(&covariance).unwrap();
1060 assert_eq!(
1061 serde_json::from_str::<Covariance>(&json).unwrap(),
1062 covariance
1063 );
1064 let ellipse = scatter.ellipse(0.5).unwrap().unwrap();
1065 for p in &points {
1066 assert!(ellipse.contains(p[0], p[1]), "{points:?}: {ellipse:?}");
1067 }
1068 let json = serde_json::to_string(&ellipse).unwrap();
1069 assert_eq!(serde_json::from_str::<Ellipse>(&json).unwrap(), ellipse);
1070 }
1071 let (sin, cos) = (PI / 6.0).sin_cos();
1074 let line: Vec<[f64; 2]> = (1..=50)
1075 .map(|t| [f64::from(t) * 10.0 * sin, f64::from(t) * 10.0 * cos])
1076 .collect();
1077 let scatter = Scatter::new(line, 50).unwrap();
1078 let ellipse = scatter.ellipse(0.95).unwrap().unwrap();
1079 assert!(
1080 same_axis(ellipse.major_heading_rad, PI / 6.0, 1e-14),
1081 "{ellipse:?}"
1082 );
1083 assert_eq!(
1084 scatter.share_inside(&ellipse).unwrap().low,
1085 1.0,
1086 "{ellipse:?}"
1087 );
1088 assert!(!ellipse.contains(250.0 * sin + 1e-6 * cos, 250.0 * cos - 1e-6 * sin));
1089 for k in 0..24 {
1091 let flat = rotated(9.0, 0.0, f64::from(k) * PI / 24.0 + 0.013);
1092 Covariance::new(flat.east_m2, flat.north_m2, flat.east_north_m2).unwrap();
1093 }
1094 }
1095
1096 #[test]
1097 fn a_circle_s_axes_stay_ordered() {
1098 let mut rng = SeededRng::seed_from_u64(66);
1101 for _ in 0..10_000 {
1102 let a = 1.0 + 1000.0 * rng.uniform();
1103 for b in [0.0, 1e-13 * a] {
1104 let sigma = Covariance::new(a, a, b).unwrap();
1105 let axes = sigma.principal_axes();
1106 assert!(
1107 axes.minor_variance_m2 <= axes.major_variance_m2,
1108 "{a}: {axes:?}"
1109 );
1110 let ellipse = Ellipse::gaussian(0.0, 0.0, &sigma, 0.9).unwrap();
1111 let json = serde_json::to_string(&ellipse).unwrap();
1112 assert_eq!(serde_json::from_str::<Ellipse>(&json).unwrap(), ellipse);
1113 }
1114 }
1115 }
1116
1117 #[test]
1118 fn an_ellipse_reads_back_through_its_checks() {
1119 let sigma = Covariance::new(4.0, 1.0, 0.5).unwrap();
1120 let ellipse = Ellipse::gaussian(10.0, -20.0, &sigma, 0.9).unwrap();
1121 let json = serde_json::to_string(&ellipse).unwrap();
1122 assert_eq!(serde_json::from_str::<Ellipse>(&json).unwrap(), ellipse);
1123 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1124 for (field, bad, what) in [
1125 ("level", 1.5, "ellipse level"),
1126 ("scale", -1.0, "ellipse scale"),
1127 (
1128 "semi_minor_m",
1129 1e3,
1130 "ellipse semi-minor axis, against zero and the semi-major",
1131 ),
1132 (
1133 "semi_minor_m",
1134 -1.0,
1135 "ellipse semi-minor axis, against zero and the semi-major",
1136 ),
1137 ("major_heading_rad", PI, "ellipse heading"),
1138 ] {
1139 let mut edited = value.clone();
1140 edited[field] = serde_json::json!(bad);
1141 let error = serde_json::from_value::<Ellipse>(edited).unwrap_err();
1142 assert!(error.to_string().contains(what), "{field}: {error}");
1143 }
1144 }
1145
1146 proptest::proptest! {
1147 #[test]
1152 fn every_point_lies_in_its_own_wide_ellipse(
1153 points in proptest::collection::vec((-1e4..1e4_f64, -1e4..1e4_f64), 2..=20),
1154 ) {
1155 let points: Vec<[f64; 2]> = points.into_iter().map(|(e, n)| [e, n]).collect();
1156 let count = points.len();
1157 let scatter = Scatter::new(points.clone(), count).unwrap();
1158 let c = scatter.covariance().unwrap();
1159 proptest::prop_assert!(Covariance::new(c.east_m2(), c.north_m2(), c.east_north_m2()).is_ok());
1160 let ellipse = scatter.ellipse(0.9999).unwrap().unwrap();
1161 proptest::prop_assert!((0.0..PI).contains(&ellipse.major_heading_rad));
1162 for p in &points {
1163 proptest::prop_assert!(ellipse.contains(p[0], p[1]), "{:?}: {:?}", p, ellipse);
1164 }
1165 }
1166 }
1167
1168 #[test]
1169 fn landings_on_a_line_and_on_a_point() {
1170 let line = Scatter::new(vec![[0.0, 10.0], [0.0, 20.0], [0.0, 30.0]], 3).unwrap();
1172 let ellipse = line.ellipse(0.95).unwrap().unwrap();
1173 assert_eq!(
1174 (ellipse.semi_minor_m, ellipse.major_heading_rad),
1175 (0.0, 0.0)
1176 );
1177 assert!(ellipse.contains(0.0, 25.0) && !ellipse.contains(0.1, 25.0));
1178 assert!(!ellipse.contains(0.0, 20.0 + 1.01 * ellipse.semi_major_m));
1179 assert_eq!(line.share_inside(&ellipse).unwrap().low, 1.0);
1180 let spot = Scatter::new(vec![[5.0, 5.0]; 4], 4).unwrap();
1182 let ellipse = spot.ellipse(0.5).unwrap().unwrap();
1183 assert_eq!((ellipse.semi_major_m, ellipse.semi_minor_m), (0.0, 0.0));
1184 assert_eq!((ellipse.center_east_m, ellipse.center_north_m), (5.0, 5.0));
1185 assert!(ellipse.contains(5.0, 5.0) && !ellipse.contains(5.0, 5.0 + 1e-9));
1186 let one = Scatter::new(vec![[1.0, 2.0]], 3).unwrap();
1188 assert_eq!(one.mean(), Some([1.0, 2.0]));
1189 assert_eq!((one.covariance(), one.ellipse(0.5).unwrap()), (None, None));
1190 let two = Scatter::new(vec![[1.0, 2.0], [3.0, 4.0]], 2).unwrap();
1191 assert!(two.ellipse(0.5).unwrap().is_some());
1192 assert_eq!(two.prediction_ellipse(0.5).unwrap(), None);
1193 let none = Scatter::new(vec![], 0).unwrap();
1194 assert_eq!((none.mean(), none.share_inside(&ellipse)), (None, None));
1195 }
1196
1197 #[test]
1198 fn missing_landings_bound_the_share() {
1199 let scatter =
1201 Scatter::new(vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]], 6).unwrap();
1202 assert_eq!(
1203 (scatter.count(), scatter.missing(), scatter.attempted()),
1204 (4, 2, 6)
1205 );
1206 let ellipse = scatter.ellipse(0.99).unwrap().unwrap();
1207 let share = scatter.share_inside(&ellipse).unwrap();
1208 assert_eq!((share.low, share.high), (4.0 / 6.0, 1.0));
1209 }
1210
1211 #[test]
1212 fn the_order_of_the_points_changes_nothing() {
1213 let sigma = rotated(400.0, 100.0, 0.4);
1214 let mut rng = SeededRng::seed_from_u64(63);
1215 let points = normal_points(&mut rng, [10.0, 20.0], &sigma, 1000);
1216 let mut reversed = points.clone();
1217 reversed.reverse();
1218 let (forward, backward) = (
1219 Scatter::new(points, 1000).unwrap(),
1220 Scatter::new(reversed, 1000).unwrap(),
1221 );
1222 assert_eq!(forward, backward);
1223 let (x, y) = (
1224 forward.ellipse(0.95).unwrap().unwrap(),
1225 backward.ellipse(0.95).unwrap().unwrap(),
1226 );
1227 assert_eq!(x.semi_major_m.to_bits(), y.semi_major_m.to_bits());
1228 assert_eq!(x.major_heading_rad.to_bits(), y.major_heading_rad.to_bits());
1229 }
1230
1231 #[test]
1232 fn a_scatter_and_a_covariance_read_back_through_their_checks() {
1233 let scatter = Scatter::new(vec![[3.0, 1.0], [1.0, 2.0]], 3).unwrap();
1234 let json = serde_json::to_string(&scatter).unwrap();
1235 assert_eq!(json, r#"{"attempted":3,"sorted":[[1.0,2.0],[3.0,1.0]]}"#);
1236 assert_eq!(serde_json::from_str::<Scatter>(&json).unwrap(), scatter);
1237 let error =
1238 serde_json::from_str::<Scatter>(r#"{"attempted":1,"sorted":[[1.0,2.0],[3.0,1.0]]}"#)
1239 .unwrap_err();
1240 assert!(error.to_string().contains("more than 1"), "{error}");
1241 let covariance = Covariance::new(4.0, 1.0, 1.5).unwrap();
1242 let json = serde_json::to_string(&covariance).unwrap();
1243 assert_eq!(
1244 json,
1245 r#"{"east_m2":4.0,"north_m2":1.0,"east_north_m2":1.5}"#
1246 );
1247 assert_eq!(
1248 serde_json::from_str::<Covariance>(&json).unwrap(),
1249 covariance
1250 );
1251 let error = serde_json::from_str::<Covariance>(
1252 r#"{"east_m2":4.0,"north_m2":1.0,"east_north_m2":2.5}"#,
1253 )
1254 .unwrap_err();
1255 assert!(
1256 error.to_string().contains("against the variances"),
1257 "{error}"
1258 );
1259 }
1260
1261 #[test]
1262 fn bad_inputs_are_refused() {
1263 assert!(matches!(
1264 Scatter::new(vec![[1.0, f64::NAN]], 1),
1265 Err(AnalysisError::Domain { what: "point in a scatter", value }) if value.is_nan()
1266 ));
1267 assert!(matches!(
1269 Scatter::new(vec![[1e200, 0.0], [-1e200, 0.0]], 2),
1270 Err(AnalysisError::Domain { what: "point in a scatter", value }) if value == 1e200
1271 ));
1272 let far = Scatter::new(vec![[1e9, -1e9], [-1e9, 1e9], [1e9, 1e9]], 3).unwrap();
1273 for ellipse in [
1274 far.ellipse(0.99).unwrap().unwrap(),
1275 far.prediction_ellipse(0.99).unwrap().unwrap(),
1276 ] {
1277 let json = serde_json::to_string(&ellipse).unwrap();
1278 assert_eq!(serde_json::from_str::<Ellipse>(&json).unwrap(), ellipse);
1279 }
1280 let huge = Covariance::new(f64::MAX, f64::MAX, f64::MAX).unwrap();
1281 assert!(matches!(
1282 Ellipse::gaussian(0.0, 0.0, &huge, 0.5),
1283 Err(AnalysisError::Domain {
1284 what: "ellipse semi-major axis",
1285 ..
1286 })
1287 ));
1288 assert!(matches!(
1289 Scatter::new(vec![[1.0, 2.0], [3.0, 4.0]], 1),
1290 Err(AnalysisError::Count {
1291 count: 2,
1292 limit: 1,
1293 ..
1294 })
1295 ));
1296 let scatter = Scatter::new(vec![[1.0, 2.0], [3.0, 5.0], [4.0, 4.0]], 3).unwrap();
1297 for level in [0.0, 1.0, -0.1, 1.5, f64::NAN, f64::INFINITY] {
1298 for result in [
1299 scatter.ellipse(level),
1300 scatter.prediction_ellipse(level),
1301 gaussian_scale(level).map(|_| None),
1302 prediction_scale(level, 10).map(|_| None),
1303 ] {
1304 assert!(matches!(
1305 result,
1306 Err(AnalysisError::Domain { what: "ellipse level", value })
1307 if value.to_bits() == level.to_bits()
1308 ));
1309 }
1310 }
1311 let error = prediction_scale(0.5, 2).unwrap_err();
1312 assert!(matches!(
1313 error,
1314 AnalysisError::TooFew {
1315 what: "landings for a prediction ellipse",
1316 count: 2,
1317 minimum: 3,
1318 }
1319 ));
1320 assert_eq!(
1321 error.to_string(),
1322 "landings for a prediction ellipse: 2 given, at least 3 needed"
1323 );
1324 assert!(matches!(
1325 Covariance::new(-1.0, 1.0, 0.0),
1326 Err(AnalysisError::Domain { what: "east variance", value }) if value == -1.0
1327 ));
1328 assert!(matches!(
1329 Covariance::new(1.0, -1.0, 0.0),
1330 Err(AnalysisError::Domain { what: "north variance", value }) if value == -1.0
1331 ));
1332 assert!(matches!(
1333 Covariance::new(1.0, 1.0, f64::INFINITY),
1334 Err(AnalysisError::Domain {
1335 what: "east-north covariance",
1336 ..
1337 })
1338 ));
1339 assert!(matches!(
1340 Covariance::new(1.0, 4.0, -2.5),
1341 Err(AnalysisError::Domain {
1342 what: "east-north covariance, against the variances",
1343 value
1344 }) if value == -2.5
1345 ));
1346 let sigma = Covariance::new(1.0, 1.0, 0.0).unwrap();
1347 assert!(matches!(
1348 Ellipse::gaussian(f64::NAN, 0.0, &sigma, 0.5),
1349 Err(AnalysisError::Domain {
1350 what: "ellipse center east",
1351 ..
1352 })
1353 ));
1354 assert!(matches!(
1355 Ellipse::gaussian(0.0, f64::INFINITY, &sigma, 0.5),
1356 Err(AnalysisError::Domain {
1357 what: "ellipse center north",
1358 ..
1359 })
1360 ));
1361 }
1362
1363 #[test]
1366 fn an_ellipse_with_the_old_uk_keys_reads_the_same() {
1367 let scatter =
1368 Scatter::new(vec![[1.0, 2.0], [3.0, -1.0], [-2.0, 0.5], [0.5, 4.0]], 4).unwrap();
1369 let ellipse = scatter.ellipse(0.9).unwrap().unwrap();
1370 let text = serde_json::to_string(&ellipse).unwrap();
1371 let old = text
1372 .replace("\"center_east_m\"", "\"centre_east_m\"")
1373 .replace("\"center_north_m\"", "\"centre_north_m\"");
1374 assert!(old.contains("\"centre_east_m\"") && old.contains("\"centre_north_m\""));
1375 assert_eq!(serde_json::from_str::<Ellipse>(&old).unwrap(), ellipse);
1376 let both = old.replace(
1377 "\"centre_east_m\"",
1378 "\"center_east_m\":0.0,\"centre_east_m\"",
1379 );
1380 assert!(serde_json::from_str::<Ellipse>(&both).is_err(), "{both}");
1381 }
1382}