Skip to main content

hpr_analysis/
ellipse.rs

1//! Landing ellipses: where a rocket's landings scatter on the ground, as an ellipse that holds a
2//! chosen share of them.
3//!
4//! **Guide:** [Monte Carlo dispersion][guide]'s *Landing ellipses* section draws one from a run
5//! and says how far to trust it.
6//!
7//! [guide]: https://nrdptel.github.io/hpr-sim/monte-carlo.html#landing-ellipses
8//!
9//! A [`Scatter`] keeps the landing points of a run (east and north of the pad, m) and the number
10//! of samples tried, so a failed flight, or one that never landed, is counted and not dropped, as
11//! in a [`Distribution`](crate::statistics::Distribution).
12//!
13//! # The ellipse of a normal spread
14//!
15//! If the landings follow a two-dimensional normal distribution with mean `μ` and covariance `Σ`,
16//! the points `x` with `(x − μ)ᵀ Σ⁻¹ (x − μ) ≤ k²` fill an ellipse centered on `μ`. Its axes lie
17//! along the eigenvectors of `Σ`, its semi-axes are `k √λ₁` and `k √λ₂` for the eigenvalues
18//! `λ₁ ≥ λ₂`, and it holds the probability `P(χ²₂ ≤ k²)`, as the left side is chi-square with two
19//! degrees of freedom. That distribution's cumulative function is `1 − e^(−x/2)`, so the ellipse
20//! holding a share `p` (its *level*) has
21//!
22//! `k² = −2 ln(1 − p)`
23//!
24//! ([`gaussian_scale`]). M. Abramowitz and I. A. Stegun, *Handbook of Mathematical Functions*, NBS
25//! AMS 55, 1964, integrate the bivariate normal density over this ellipse to `1 − e^(−k²/2)`
26//! (p. 940, eq. 26.3.21), the chi-square function with two degrees of freedom (eq. 26.4.5,
27//! p. 941). B. Wang, W. Shi and Z. Miao, "Confidence analysis of standard deviational ellipse and
28//! its extension into higher dimensional Euclidean space", *PLoS ONE* 10(3), e0118537, 2015,
29//! <https://doi.org/10.1371/journal.pone.0118537>, derive the axes (eqs. 13–16) and the level
30//! (eqs. 19–20). For `p` = 50%, 90%, 95% and 99%, `k²` is `2 ln 2`, `2 ln 10`, `2 ln 20` and
31//! `4 ln 10`: 1.386, 4.605, 5.991 and 9.210, as the NIST/SEMATECH *e-Handbook of Statistical
32//! Methods* tabulates the last three (§1.3.6.7.4,
33//! <https://www.itl.nist.gov/div898/handbook/eda/section3/eda3674.htm>).
34//!
35//! For a symmetric 2 × 2 matrix `Σ = [[a, b], [b, c]]` (`a` the east variance, `c` the north, `b`
36//! their covariance) the eigenvalues are `λ = (a + c)/2 ± √(((a − c)/2)² + b²)` and the major
37//! axis makes the angle `θ = ½ atan2(2b, a − c)` with east, counter-clockwise. It is reported as
38//! a heading, clockwise from north: `π/2 − θ`, in `[0, π)`. A circle (`a = c`, `b = 0`) has no
39//! major axis; its heading is reported as east's, `π/2`.
40//!
41//! [`Scatter::ellipse`] puts the sample's mean and covariance in place of `μ` and `Σ`, its axes
42//! measured from the points themselves ([`Scatter::principal_axes`]) so that a very narrow spread
43//! keeps its width. The mean
44//! and covariance are taken on the points shifted by the first (sorted) one, the covariance with
45//! `n − 1` and two passes, as [`Distribution`](crate::statistics::Distribution)'s are (T. F.
46//! Chan, G. H. Golub and R. J. LeVeque, *The American Statistician* 37(3), 242–247, 1983).
47//!
48//! # The ellipse a new flight lands in
49//!
50//! A sample's mean and covariance are estimates, so the ellipse drawn from them holds a little
51//! less than `p` of the flights still to come; with few samples, much less. For normal landings
52//! the region a new flight lands in with probability exactly `p`, given `n` flights, is
53//! `(x − x̄)ᵀ S⁻¹ (x − x̄) ≤ k²` with
54//!
55//! `k² = 2 (n + 1)(n − 1) / (n (n − 2)) · F₂,ₙ₋₂(p) = ((n² − 1)/n) ((1 − p)^(−2/(n − 2)) − 1)`
56//!
57//! ([`prediction_scale`], [`Scatter::prediction_ellipse`]). This follows from the new point
58//! `x − x̄` being normal with covariance `(1 + 1/n) Σ` and independent of `S`, so
59//! `n/(n + 1) (x − x̄)ᵀ S⁻¹ (x − x̄)` is Hotelling's `T²` with `n − 1` degrees of freedom, which
60//! is `2(n − 1)/(n − 2)` times an `F` with 2 and `n − 2` (H. Hotelling, "The generalization of
61//! Student's ratio", *Annals of Mathematical Statistics* 2(3), 360–378, 1931, cited for the
62//! distribution and not consulted). The formula is checked against the NIST/SEMATECH
63//! *e-Handbook of Statistical Methods*, §6.5.4.3.4, which gives the same limit,
64//! `p(m + 1)(m − 1)/(m² − mp) F(p, m − p)` for `p` dimensions and `m` points, after T. P. Ryan,
65//! *Statistical Methods for Quality Improvement*, 2000, ch. 9
66//! (<https://www.itl.nist.gov/div898/handbook/pmc/section5/pmc5434.htm>). The `F` distribution
67//! with 2 and `m` degrees of freedom has the cumulative function `1 − (1 + 2f/m)^(−m/2)` (A&S
68//! eq. 26.6.4, p. 946), which inverts in closed form. As `n` grows, `k²` falls to the
69//! normal ellipse's `−2 ln(1 − p)`: at 200 flights and 95% it is 2.6% above it, and the semi-axes
70//! 1.3% longer.
71//!
72//! # Whether the landings are normal
73//!
74//! Neither ellipse is right if the landings aren't normal, and they often aren't: a wind whose
75//! heading is uncertain spreads them along an arc. [`Scatter::share_inside`] counts the landings
76//! an ellipse really holds. A sample that gave no landing could have landed inside or outside,
77//! so the share is a [`Share`]: a lower bound counting it outside, an upper bound counting it
78//! inside. A share far from the level means the ellipse is the wrong shape for this run; a share
79//! close to it is consistent with normal landings, not proof of them. With few landings the share
80//! runs high, as the ellipse is fitted to the same points: three points are each exactly
81//! `√(4/3)` standard deviations out, so even the 50% ellipse holds all three.
82//!
83//! Every sum runs over the points sorted (east, then north), so an ellipse is bit for bit the same
84//! however the points were computed or ordered.
85
86use serde::{Deserialize, Serialize};
87
88use crate::error::AnalysisError;
89use crate::statistics::Share;
90
91/// The scale `k` of the ellipse holding the share `level` of a normal spread whose mean and
92/// covariance are known: `k² = −2 ln(1 − level)` (the module's docs).
93///
94/// # Errors
95///
96/// [`AnalysisError::Domain`] for a level outside `(0, 1)`.
97pub fn gaussian_scale(level: f64) -> Result<f64, AnalysisError> {
98    check_level(level)?;
99    Ok((-2.0 * (-level).ln_1p()).sqrt())
100}
101
102/// The scale `k` of the ellipse a new flight lands in with probability `level`, from `count`
103/// normal landings whose mean and covariance were estimated:
104/// `k² = ((n² − 1)/n) ((1 − level)^(−2/(n − 2)) − 1)` (the module's docs).
105///
106/// # Errors
107///
108/// - [`AnalysisError::Domain`] for a level outside `(0, 1)`.
109/// - [`AnalysisError::TooFew`] for fewer than 3 landings, which leave no degrees of freedom.
110pub 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    // Cast: a count of landings is far below 2⁵³.
120    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
125/// Refuses a level outside `(0, 1)`, or one that isn't a number.
126fn 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/// The covariance of a spread of points on the ground, m². It serializes as its three entries and
138/// reads back through [`Covariance::new`]'s checks.
139#[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/// The serialized form of a [`Covariance`].
148#[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/// The axes of a [`Covariance`]: its eigenvalues, and the heading of the larger one's eigenvector.
165#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
166pub struct PrincipalAxes {
167    /// The variance along the major axis, the larger eigenvalue `λ₁`, m².
168    pub major_variance_m2: f64,
169    /// The variance along the minor axis, the smaller eigenvalue `λ₂`, m²; zero for points on a
170    /// line.
171    pub minor_variance_m2: f64,
172    /// The major axis's heading, clockwise from north, in `[0, π)`; east's, `π/2`, for a circle.
173    pub major_heading_rad: f64,
174}
175
176impl Covariance {
177    /// The covariance with the variances `east_m2` and `north_m2` and the covariance
178    /// `east_north_m2`.
179    ///
180    /// # Errors
181    ///
182    /// [`AnalysisError::Domain`] for an entry that isn't finite, a negative variance, or a
183    /// covariance larger than the variances allow (`|east_north| > √east √north`, so the matrix
184    /// isn't positive semi-definite), beyond four units of rounding: a covariance of points on a
185    /// line, computed in floating point, can pass the bound by that much.
186    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        // Square roots, not squares, so that large entries can't overflow.
202        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    /// The east variance, m².
216    pub fn east_m2(&self) -> f64 {
217        self.east_m2
218    }
219
220    /// The north variance, m².
221    pub fn north_m2(&self) -> f64 {
222        self.north_m2
223    }
224
225    /// The covariance of east and north, m².
226    pub fn east_north_m2(&self) -> f64 {
227        self.east_north_m2
228    }
229
230    /// The eigenvalues and the major axis's heading (the module's docs). The smaller eigenvalue is
231    /// taken as `(ac − b²)/λ₁`, the determinant over the larger, rather than
232    /// `(a + c)/2 − √(…)`, whose subtraction loses every digit of a spread much narrower than it
233    /// is long; it is cut at zero, since rounding can take the determinant just below for points
234    /// on a line.
235    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        // Rounding can put a circle's `a²/a` a unit above `a`: the minor is never the larger.
240        let minor = if major > 0.0 {
241            (determinant / major).max(0.0).min(major)
242        } else {
243            0.0
244        };
245        // Counter-clockwise from east, in [−π/2, π/2].
246        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            // `π/2 − θ` lies in [0, π]; π is the same axis as 0 (θ = −π/2, from `atan2(−0, −x)`).
252            major_heading_rad: if heading >= std::f64::consts::PI {
253                0.0
254            } else {
255                heading
256            },
257        }
258    }
259}
260
261/// An ellipse on the ground, holding a share of the landings. Built by [`Ellipse::gaussian`],
262/// [`Scatter::ellipse`] or [`Scatter::prediction_ellipse`]; it serializes as its fields and reads
263/// back through checks on each.
264#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
265#[serde(try_from = "EllipseData")]
266#[non_exhaustive]
267pub struct Ellipse {
268    /// Its level, in `(0, 1)`: the share of a normal spread it holds, or for a prediction
269    /// ellipse the probability that the next flight lands inside it.
270    pub level: f64,
271    /// Its scale `k`: the semi-axes are `k` standard deviations along each axis.
272    pub scale: f64,
273    /// Its center, east of the pad, m.
274    pub center_east_m: f64,
275    /// Its center, north of the pad, m.
276    pub center_north_m: f64,
277    /// Half its long axis, m.
278    pub semi_major_m: f64,
279    /// Half its short axis, m; zero for landings on a line.
280    pub semi_minor_m: f64,
281    /// Its long axis's heading, clockwise from north, in `[0, π)`.
282    pub major_heading_rad: f64,
283}
284
285/// The serialized form of an [`Ellipse`].
286#[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    /// The ellipse holding the share `level` of a normal spread with the mean
349    /// `(center_east_m, center_north_m)` and the covariance `covariance`, both known: its scale is
350    /// [`gaussian_scale`].
351    ///
352    /// # Errors
353    ///
354    /// [`AnalysisError::Domain`] for a level outside `(0, 1)`, a center that isn't finite, or a
355    /// covariance so large that an axis overflows.
356    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    /// The ellipse `scale` standard deviations out along `axes`.
387    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    /// Its area, `π a b`, m².
400    pub fn area_m2(&self) -> f64 {
401        std::f64::consts::PI * self.semi_major_m * self.semi_minor_m
402    }
403
404    /// Whether the point `east_m`, `north_m` (m from the pad) lies inside or on the ellipse.
405    ///
406    /// Neither semi-axis is taken below `10⁻¹²` of the ellipse's size and distance from the pad
407    /// (`a + |center east| + |center north|`). Rounding in the center, the heading and this
408    /// test's rotation puts a point that lies on a flat ellipse's axis (a zero minor axis, from
409    /// landings on a line) a few units of rounding off it, and the floor keeps it inside. Against
410    /// any real spread the floor is far below a millimeter.
411    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        // Along the major axis, whose direction is (sin, cos) in (east, north), and across it.
415        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            // An ellipse of no size at the pad holds the pad alone.
424            east == 0.0 && north == 0.0
425        }
426    }
427}
428
429/// Points on the ground (east and north of the pad, m), sorted, and how many samples were tried.
430/// It serializes as those two, and reads back through [`Scatter::new`]'s checks.
431#[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/// The serialized form of a [`Scatter`].
439#[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    /// The largest distance east or north of the pad a point may have, m: 10⁹ m, 25 times round
456    /// the Earth, and small enough that every square and sum of a scatter stays finite.
457    pub const MAX_COORDINATE_M: f64 = 1e9;
458
459    /// The scatter of `points`, each `[east_m, north_m]`, from `attempted` samples (those that
460    /// gave no point make up the difference).
461    ///
462    /// # Errors
463    ///
464    /// - [`AnalysisError::Domain`] for a coordinate that isn't finite, or is more than
465    ///   [`Scatter::MAX_COORDINATE_M`] from the pad.
466    /// - [`AnalysisError::Count`] for more points than samples.
467    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    /// The samples tried.
491    pub fn attempted(&self) -> usize {
492        self.attempted
493    }
494
495    /// The samples that gave a point.
496    pub fn count(&self) -> usize {
497        self.sorted.len()
498    }
499
500    /// The samples that gave no point: failed, or never landed.
501    pub fn missing(&self) -> usize {
502        self.attempted - self.sorted.len()
503    }
504
505    /// The points, sorted by east, then north.
506    pub fn points(&self) -> &[[f64; 2]] {
507        &self.sorted
508    }
509
510    /// The mean point, `x₀ + Σ(xᵢ − x₀)/n` with `x₀` the first; `None` with no points.
511    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    /// The mean of the points less `shift`.
518    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        // Cast: a count of points is far below 2⁵³.
523        let n = self.sorted.len() as f64;
524        [east / n, north / n]
525    }
526
527    /// The sample covariance, `Σ(dᵢ − d̄)(dᵢ − d̄)ᵀ/(n − 1)` with `dᵢ = xᵢ − x₀`; `None` with
528    /// fewer than two points.
529    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        // Cast: a count of points is far below 2⁵³.
544        let dof = (self.sorted.len() - 1) as f64;
545        let (east_m2, north_m2) = (ee / dof, nn / dof);
546        // The Cauchy–Schwarz bound holds to rounding, which can break it for points on a line
547        // (any two points): cut there, so the covariance passes `Covariance::new` and reads back.
548        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    /// The ellipse holding the share `level` of a normal spread with this scatter's mean and
557    /// covariance ([`gaussian_scale`]); `None` with fewer than two points.
558    ///
559    /// # Errors
560    ///
561    /// [`AnalysisError::Domain`] for a level outside `(0, 1)`.
562    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    /// The ellipse a new flight lands in with probability `level`, if the landings are normal,
568    /// allowing for the mean and covariance being estimated from this scatter
569    /// ([`prediction_scale`]); `None` with fewer than three points.
570    ///
571    /// # Errors
572    ///
573    /// [`AnalysisError::Domain`] for a level outside `(0, 1)`.
574    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    /// The ellipse `scale` standard deviations out from the mean; `None` with fewer than two
584    /// points.
585    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    /// The scatter's axes: the heading of its [`Covariance::principal_axes`], and the variances
592    /// as the mean squares of the points' distances along and across that heading,
593    /// `Σ(uᵀdᵢ)²/(n − 1)` with `dᵢ` a point less the mean. With the heading exact these are the
594    /// eigenvalues, and an error `δ` in the heading moves them by only `δ² (λ₁ − λ₂)` (they are
595    /// Rayleigh quotients). So a spread far narrower than it is long keeps its width, which the
596    /// covariance's three entries, each rounded to about `ε λ₁`, can't carry. `None` with fewer
597    /// than two points.
598    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        // Cast: a count of points is far below 2⁵³.
611        let dof = (self.sorted.len() - 1) as f64;
612        let (along, across) = (along_squares / dof, across_squares / dof);
613        // Near a circle the heading means nothing and rounding can leave the across spread the
614        // larger: then the axis across is the major one.
615        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    /// Bounds on the share of the samples tried that landed inside or on `ellipse` ([`Share`]):
636    /// a sample with no point counts as outside for `low`, inside for `high`. `None` with no
637    /// samples tried.
638    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        // Cast: counts far below 2⁵³.
648        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    /// The covariance with the variances `major` and `minor` along axes whose major one has the
665    /// heading `heading` (clockwise from north): `R diag(major, minor) Rᵀ` with the major axis's
666    /// direction `(sin h, cos h)` in (east, north).
667    fn rotated(major: f64, minor: f64, heading: f64) -> Covariance {
668        let (s, c) = heading.sin_cos();
669        // u = (s, c), v = (c, −s): Σ = major u uᵀ + minor v vᵀ.
670        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    /// Relative closeness, or absolute near zero.
678    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    /// The same heading for an axis, which has no sense: `h` and `h ± π` are one axis.
683    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        // χ² with two degrees of freedom at 90%, 95% and 99%, as the NIST/SEMATECH e-Handbook's
691        // table prints them (§1.3.6.7.4, three decimals).
692        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        // To nine figures, the closed forms 2 ln 2, 2 ln 10, 2 ln 20 and 4 ln 10, with ln 2 =
697        // 0.693147181, ln 10 = 2.302585093 and ln 20 = 2.995732274.
698        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        // 1 − e^(−1) is exactly the level of k² = 2, up to the level's rounding.
708        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        // Every heading of the major axis, round and odd, and every shape: long, round-ish, flat.
715        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                // The heading is ill-conditioned as the shape nears a circle: its error grows like
726                // ε λ₁ / (λ₁ − λ₂).
727                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        // Along the axes, exactly; a circle reports east's heading.
735        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        // Equal variances and a positive covariance: the major axis runs north-east.
763        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    /// The probability a normal spread with mean 0 and covariance `sigma` puts inside `ellipse`
772    /// (centered at 0), by integrating its density along rays from the center.
773    ///
774    /// Along the unit direction `u`, the density is `exp(−r² q/2) / (2π √det Σ)` with
775    /// `q = uᵀ Σ⁻¹ u`, so the ray out to the ellipse's edge at `R(u)` carries
776    /// `∫₀ᴿ exp(−r² q/2) r dr = (1 − exp(−R² q/2))/q`. The edge comes from the ellipse's own axes
777    /// and heading, `Σ⁻¹` from the matrix's entries, so the two meet only if the axes are right.
778    /// The integrand is smooth and periodic in the ray's angle, so the trapezoid rule converges
779    /// geometrically.
780    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        // An ellipse turned 0.1 rad (6°) off the axes holds visibly less: the check can fail. (A
825        // turn's loss is of second order, so a much smaller one would hide in the margin.)
826        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        // Four points a distance s₁ either way along a north-east axis and s₂ across it, about
836        // (100, −50): Σ(d dᵀ) = 2 s₁² u uᵀ + 2 s₂² v vᵀ over n − 1 = 3.
837        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        // The prediction ellipse is wider than the normal one at the same level.
866        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    /// `count` points from the normal spread with mean `center` and covariance `sigma`, by its
873    /// Cholesky factor `L` (`Σ = L Lᵀ`) times standard normal pairs.
874    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        // 100,000 landings from a known normal spread: the covariance and the share inside each
894        // ellipse within five standard errors of the truth.
895        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        // A sample (co)variance's standard error: √((σᵢⱼ² + σᵢᵢ σⱼⱼ)/(n − 1)).
903        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            // The share of the sample inside its own ellipse, and inside the true one.
928            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        // Many runs of a few flights each, and one more flight: the new one lands inside the
948        // prediction ellipse drawn from the few at its level, within five standard errors, and
949        // inside the normal one visibly less often.
950        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        // n = 5 at 95%: k² = 2·6·4/(5·3) F₂,₃(0.95), with F₂,₃(0.95) = 1.5 (0.05^(−2/3) − 1) =
976        // 9.552, an F table's 9.55.
977        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        // As the flights grow, it falls to the normal ellipse's.
986        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        // The excess falls like 1/n.
994        assert!(close(last, normal, 1e-5), "{last}");
995        let at_200 = prediction_scale(0.95, 200).unwrap().powi(2) / (normal * normal);
996        // 6.1443 against 5.9915: 2.55% more, the semi-axes 1.3% longer.
997        assert!((at_200 - 1.025_513).abs() < 1e-6, "{at_200}");
998    }
999
1000    #[test]
1001    fn a_narrow_spread_keeps_its_width() {
1002        // A spread 10⁸ times longer than it is wide, at an odd heading: the minor variance is
1003        // 10⁻¹² m², 10⁻¹⁶ of the major, below the rounding of the covariance's entries, from which
1004        // any formula gave 0 and an ellipse holding almost none of the landings.
1005        // The points are drawn along the axes, `μ + z₁ √λ₁ u + z₂ √λ₂ v`: a Cholesky factor's
1006        // `√(c − l₂₁²)` would cancel to noise at this width.
1007        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        // Any two landings lie on a line. Their covariance must pass its own checks and read
1040        // back, and their ellipses hold both: each is √½ standard deviations from the mean.
1041        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        // Fifty landings evenly along a line at 30° from north: the 95% ellipse holds every one,
1072        // and nothing a micrometer off the line.
1073        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        // A rank-one covariance computed in floating point passes its checks at every heading.
1090        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        // `a²/a` rounds a unit above `a` for some `a`: the minor axis must still not pass the
1099        // major, or the ellipse fails its own checks on reading back.
1100        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        /// Whatever the points: the covariance passes its own checks, the heading lies in
1148        /// [0, π), and for 2 to 20 points the 99.99% ellipse holds every one of them. A sample
1149        /// point is at most (n − 1)/√n standard deviations from the mean (its leverage is at most
1150        /// 1), which for 20 points is √18.05, inside the 99.99% ellipse's √18.42.
1151        #[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        // Due north of the pad: a flat ellipse holding its own line.
1171        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        // Every flight on one spot: an ellipse of no size, holding that spot alone.
1181        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        // Too few points: no covariance, or no prediction.
1187        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        // Two of six flights gave no landing: between 4/6 and 6/6 of them landed inside.
1200        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        // Past 10⁹ m the squares could overflow; at it, they don't.
1268        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    /// An ellipse written before the move to US spelling, with `centre_east_m` and `centre_north_m`,
1364    /// reads as the same ellipse; a document holding both spellings of one key is refused.
1365    #[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}