Skip to main content

hpr_analysis/sensitivity/
morris.rs

1//! Morris's screening: elementary effects along random one-at-a-time paths through a grid.
2//!
3//! **Guide:** [Sensitivity analysis][guide]'s *Morris screening* section.
4//!
5//! [guide]: https://nrdptel.github.io/hpr-sim/sensitivity.html#morris-screening
6//!
7//! # The method
8//!
9//! Each factor's range is mapped onto `[0, 1]` and cut into a grid of `p` levels, `0, 1/(p − 1),
10//! …, 1`, with `p` even. A step is `Δ = p / (2 (p − 1))`, half the levels. The *elementary
11//! effect* of factor `i` at a grid point `x` is
12//!
13//! `dᵢ(x) = (y(x₁, …, xᵢ + Δ, …, x_k) − y(x)) / Δ`
14//!
15//! for the points with `xᵢ ≤ 1 − Δ`. As `x` runs over the grid these form a finite
16//! distribution, `Fᵢ`, of `p^(k−1) · p/2` effects. Its mean `μᵢ`, the mean of its absolute values
17//! `μ*ᵢ`, and its standard deviation `σᵢ` say how much the factor matters: a large `μ*` is an
18//! important factor, a large `σ` one that is nonlinear or acts with others, and a `μ*` near zero
19//! one that can be left at its nominal value. Since `x` is on `[0, 1]`, `dᵢ` is in the output's
20//! units per the factor's whole range: for `y = c x_i` in physical units, `dᵢ = c (high − low)`.
21//!
22//! A *path* (Morris's *trajectory*) is `k + 1` points: a random start, then each factor stepped
23//! once by `±Δ`, in a random order. Each step gives one elementary effect, so `r` paths give `r`
24//! effects of each factor from `r (k + 1)` runs. Morris's construction (his matrix `B*`, p. 164) draws
25//! the start's coordinates from the levels `0, …, 1 − Δ`, a sign for each factor, and an order;
26//! a factor whose sign is negative starts `Δ` higher and steps down. Each effect is then drawn
27//! uniformly from `Fᵢ` (Morris, p. 164), so the means of the `r` effects estimate `μᵢ`, `μ*ᵢ` and
28//! `σᵢ`, with a sampling error that falls as `1/√r` ([`ElementaryEffects`]).
29//!
30//! M. D. Morris, "Factorial sampling plans for preliminary computational experiments",
31//! *Technometrics* 33(2), 161–174, 1991, <https://doi.org/10.2307/1269043>, defines the effects
32//! (his eq. (1), p. 163), the grid, `Δ` and the paths. F. Campolongo, J. Cariboni and A.
33//! Saltelli, "An effective screening design for sensitivity analysis of large models",
34//! *Environmental Modeling & Software* 22, 1509–1518, 2007,
35//! <https://doi.org/10.1016/j.envsoft.2006.10.004>, add `μ*`, which doesn't let effects of
36//! opposite signs cancel (pp. 1511–1512), and find, by experiment rather than proof, that it
37//! ranks factors as the total Sobol' index does (p. 1517). Not always. A step is about half the
38//! range, so it misses a response that repeats over about half the range: on Ishigami's function
39//! (on `[−π, π]`) a step of `x₂` is `pπ/(p − 1)`, near the period `π` of `sin² x₂`. At four levels
40//! `μ*` puts `x₂` first (7.875 against 7.704 for `x₁`), the total index `x₁` (0.558 against
41//! 0.442); at six or more, `μ*` puts `x₂` last (2.687 at six levels), though its first-order
42//! index is the largest.
43//!
44//! [`Morris::population`] computes `Fᵢ`'s three moments exactly, by running the model at every
45//! grid point: the numbers the paths estimate, for a model cheap enough to run `p^k` times.
46
47use hpr_core::random::SeededRng;
48use serde::{Deserialize, Serialize};
49
50use super::{Factor, check_factors, check_outputs, check_size};
51use crate::error::AnalysisError;
52
53/// The most grid points [`Morris::population`] will run the model at.
54pub const MAX_POPULATION_POINTS: usize = 1 << 24;
55
56/// The most levels a grid may have: far more than a screening uses (the guide suggests 4).
57pub const MAX_LEVELS: usize = 1 << 16;
58
59/// A Morris screening: the factors, the grid's number of levels and the number of paths. It
60/// serializes as its fields, and reads back through [`Morris::new`]'s checks.
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62#[serde(try_from = "MorrisData")]
63pub struct Morris {
64    factors: Vec<Factor>,
65    levels: usize,
66    paths: usize,
67}
68
69/// The serialized form of a [`Morris`].
70#[derive(Deserialize)]
71#[serde(deny_unknown_fields)]
72struct MorrisData {
73    factors: Vec<Factor>,
74    levels: usize,
75    paths: usize,
76}
77
78impl TryFrom<MorrisData> for Morris {
79    type Error = AnalysisError;
80
81    fn try_from(data: MorrisData) -> Result<Self, AnalysisError> {
82        Self::new(data.factors, data.levels, data.paths)
83    }
84}
85
86/// One path: where it starts on the grid, and which way and in which order its factors step.
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88#[non_exhaustive]
89pub struct Path {
90    /// Each factor's level at the start, `0` to `p − 1`.
91    pub start: Vec<usize>,
92    /// The factors in the order they step.
93    pub order: Vec<usize>,
94    /// Each factor's direction: up `Δ` (`true`) or down.
95    pub up: Vec<bool>,
96}
97
98/// The points of a Morris screening, path after path, each path's `k + 1` points in order. It is
99/// not serialized: [`Morris::design`] rebuilds it, bit for bit, from the screening and its seed.
100#[derive(Debug, Clone, PartialEq)]
101pub struct MorrisDesign {
102    factors: Vec<Factor>,
103    levels: usize,
104    paths: Vec<Path>,
105}
106
107/// A factor's elementary effects: from a screening's paths ([`MorrisDesign::analyze`]), or all
108/// of them on the grid ([`Morris::population`]).
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110#[non_exhaustive]
111pub struct ElementaryEffects {
112    /// The factor's name.
113    pub name: String,
114    /// How many effects: one per path, or every one on the grid.
115    pub count: usize,
116    /// Their mean, `μ`, in the output's units per the factor's whole range.
117    pub mean: f64,
118    /// The mean of their absolute values, `μ*`, in the same units.
119    pub mean_absolute: f64,
120    /// Their standard deviation, `σ`: from paths with `count − 1` in the denominator, of the
121    /// whole grid with `count`.
122    pub standard_deviation: f64,
123    /// The standard error of `μ*` from paths: the standard deviation of the absolute effects
124    /// over `√count`. Zero for the whole grid, which is exact.
125    pub mean_absolute_standard_error: f64,
126}
127
128/// What a Morris screening found: each factor's effects, in the factors' order.
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130#[non_exhaustive]
131pub struct Screening {
132    /// The grid's number of levels, `p`.
133    pub levels: usize,
134    /// The step, `Δ`, as a fraction of each factor's range.
135    pub step: f64,
136    /// Each factor's effects.
137    pub effects: Vec<ElementaryEffects>,
138}
139
140impl Morris {
141    /// A screening of `factors` on a grid of `levels` levels, with `paths` paths.
142    ///
143    /// # Errors
144    ///
145    /// - [`AnalysisError::TooFew`] with no factors, fewer than 2 levels, or fewer than 2 paths
146    ///   (a standard deviation needs two).
147    /// - [`AnalysisError::DuplicateFactor`] for two factors with one name.
148    /// - [`AnalysisError::Unsupported`] for an odd number of levels: Morris's step `Δ` is a whole
149    ///   number of levels only for an even one.
150    /// - [`AnalysisError::Count`] for more than [`MAX_LEVELS`] levels, or a design of more than
151    ///   [`MAX_DESIGN_POINTS`](super::MAX_DESIGN_POINTS) points or
152    ///   [`MAX_DESIGN_VALUES`](super::MAX_DESIGN_VALUES) coordinates.
153    pub fn new(factors: Vec<Factor>, levels: usize, paths: usize) -> Result<Self, AnalysisError> {
154        check_factors(&factors)?;
155        if levels < 2 {
156            return Err(AnalysisError::TooFew {
157                what: "grid levels",
158                count: levels,
159                minimum: 2,
160            });
161        }
162        if levels > MAX_LEVELS {
163            return Err(AnalysisError::Count {
164                what: "grid levels",
165                count: levels,
166                limit: MAX_LEVELS,
167            });
168        }
169        if !levels.is_multiple_of(2) {
170            return Err(AnalysisError::Unsupported(format!(
171                "{levels} grid levels: Morris's step is a whole number of levels only for an even number"
172            )));
173        }
174        if paths < 2 {
175            return Err(AnalysisError::TooFew {
176                what: "Morris paths",
177                count: paths,
178                minimum: 2,
179            });
180        }
181        let k = factors.len();
182        check_size("Morris points", paths, k.saturating_add(1), k)?;
183        Ok(Self {
184            factors,
185            levels,
186            paths,
187        })
188    }
189
190    /// The factors.
191    pub fn factors(&self) -> &[Factor] {
192        &self.factors
193    }
194
195    /// The grid's number of levels, `p`.
196    pub fn levels(&self) -> usize {
197        self.levels
198    }
199
200    /// The number of paths, `r`.
201    pub fn paths(&self) -> usize {
202        self.paths
203    }
204
205    /// The step `Δ = p / (2 (p − 1))`, as a fraction of each factor's range.
206    pub fn step(&self) -> f64 {
207        step(self.levels)
208    }
209
210    /// The paths of a screening seeded with `seed`. Path `j` draws from its own stream,
211    /// [`SeededRng::for_stream`]`(seed, &[j])`: its start's levels, then a direction for each
212    /// factor, then the order (a Fisher–Yates shuffle), so it is the same however many paths are
213    /// drawn.
214    pub fn design(&self, seed: u64) -> MorrisDesign {
215        let k = self.factors.len();
216        let half = self.levels / 2;
217        let paths = (0..self.paths)
218            .map(|j| {
219                // Cast: a path's index is far below 2⁶⁴.
220                let mut rng = SeededRng::for_stream(seed, &[j as u64]);
221                let start: Vec<usize> = (0..k).map(|_| pick(&mut rng, half)).collect();
222                let up: Vec<bool> = (0..k).map(|_| rng.uniform() < 0.5).collect();
223                let mut order: Vec<usize> = (0..k).collect();
224                for i in (1..k).rev() {
225                    order.swap(i, pick(&mut rng, i + 1));
226                }
227                // A factor that steps down starts half the levels higher.
228                let start = start
229                    .iter()
230                    .zip(&up)
231                    .map(|(&level, &up)| if up { level } else { level + half })
232                    .collect();
233                Path { start, order, up }
234            })
235            .collect();
236        MorrisDesign {
237            factors: self.factors.clone(),
238            levels: self.levels,
239            paths,
240        }
241    }
242
243    /// Draws the design seeded with `seed`, runs `model` at each of its points in order, and
244    /// analyses the outputs ([`MorrisDesign::analyze`]).
245    ///
246    /// # Errors
247    ///
248    /// As [`MorrisDesign::analyze`].
249    pub fn screen(
250        &self,
251        seed: u64,
252        mut model: impl FnMut(&[f64]) -> f64,
253    ) -> Result<Screening, AnalysisError> {
254        let design = self.design(seed);
255        let outputs: Vec<f64> = design.points().iter().map(|x| model(x)).collect();
256        design.analyze(&outputs)
257    }
258
259    /// Each factor's elementary effects over the whole grid: `Fᵢ`'s mean, mean absolute value and
260    /// standard deviation, exactly, from `model` run once at each of the grid's `p^k` points.
261    /// These are what [`Morris::screen`] estimates.
262    ///
263    /// # Errors
264    ///
265    /// - [`AnalysisError::Count`] for a grid of more than [`MAX_POPULATION_POINTS`] points, its
266    ///   count `p^k` saturating at `usize::MAX`.
267    /// - [`AnalysisError::Output`] for an output that isn't finite, at its grid point's index
268    ///   (factor 0's level the fastest-changing digit).
269    pub fn population(
270        &self,
271        mut model: impl FnMut(&[f64]) -> f64,
272    ) -> Result<Vec<ElementaryEffects>, AnalysisError> {
273        let k = self.factors.len();
274        let p = self.levels;
275        let total = (0..k).fold(1_usize, |n, _| n.saturating_mul(p));
276        if total > MAX_POPULATION_POINTS {
277            return Err(AnalysisError::Count {
278                what: "Morris grid points",
279                count: total,
280                limit: MAX_POPULATION_POINTS,
281            });
282        }
283        let mut levels = vec![0_usize; k];
284        let mut point = vec![0.0; k];
285        let mut outputs = Vec::with_capacity(total);
286        for index in 0..total {
287            let mut rest = index;
288            for (level, (x, factor)) in levels.iter_mut().zip(point.iter_mut().zip(&self.factors)) {
289                *level = rest % p;
290                rest /= p;
291                *x = factor.at(unit(*level, p));
292            }
293            let y = model(&point);
294            if !y.is_finite() {
295                return Err(AnalysisError::Output { index, value: y });
296            }
297            outputs.push(y);
298        }
299        let delta = self.step();
300        let half = p / 2;
301        let mut stride = 1;
302        let mut effects = Vec::with_capacity(k);
303        for factor in &self.factors {
304            let mut ds = Vec::with_capacity(total / 2);
305            for (index, &lower) in outputs.iter().enumerate() {
306                if (index / stride) % p < half {
307                    ds.push((outputs[index + half * stride] - lower) / delta);
308                }
309            }
310            effects.push(moments(factor.name(), &ds, false));
311            stride *= p;
312        }
313        Ok(effects)
314    }
315}
316
317impl MorrisDesign {
318    /// The paths.
319    pub fn paths(&self) -> &[Path] {
320        &self.paths
321    }
322
323    /// The number of points, `r (k + 1)`.
324    pub fn len(&self) -> usize {
325        self.paths.len() * (self.factors.len() + 1)
326    }
327
328    /// Whether there are no points; never, as a screening has at least two paths.
329    pub fn is_empty(&self) -> bool {
330        self.len() == 0
331    }
332
333    /// The points, in physical units, path after path: each path's start, then the point after
334    /// each step in its order.
335    pub fn points(&self) -> Vec<Vec<f64>> {
336        let half = self.levels / 2;
337        let p = self.levels;
338        let mut points = Vec::with_capacity(self.len());
339        for path in &self.paths {
340            let mut levels = path.start.clone();
341            points.push(self.at(&levels));
342            for &i in &path.order {
343                levels[i] = if path.up[i] {
344                    levels[i] + half
345                } else {
346                    levels[i] - half
347                };
348                debug_assert!(levels[i] < p, "a path stays on the grid");
349                points.push(self.at(&levels));
350            }
351        }
352        points
353    }
354
355    /// The point at grid levels `levels`, in physical units.
356    fn at(&self, levels: &[usize]) -> Vec<f64> {
357        levels
358            .iter()
359            .zip(&self.factors)
360            .map(|(&level, factor)| factor.at(unit(level, self.levels)))
361            .collect()
362    }
363
364    /// Each factor's elementary effects from `outputs`, the model's output at each of
365    /// [`MorrisDesign::points`] in order. A step up of factor `i` from point `x` to `x′` gives
366    /// `(y(x′) − y(x))/Δ`, a step down `(y(x) − y(x′))/Δ`, so either is the effect at the lower
367    /// point.
368    ///
369    /// # Errors
370    ///
371    /// - [`AnalysisError::Length`] unless there is one output per point.
372    /// - [`AnalysisError::Output`] for an output that isn't finite.
373    pub fn analyze(&self, outputs: &[f64]) -> Result<Screening, AnalysisError> {
374        check_outputs(outputs, self.len())?;
375        let k = self.factors.len();
376        let delta = step(self.levels);
377        let mut effects = (0..k)
378            .map(|_| Vec::with_capacity(self.paths.len()))
379            .collect::<Vec<_>>();
380        for (path, ys) in self.paths.iter().zip(outputs.chunks_exact(k + 1)) {
381            for (s, &i) in path.order.iter().enumerate() {
382                let change = ys[s + 1] - ys[s];
383                effects[i].push(if path.up[i] { change } else { -change } / delta);
384            }
385        }
386        Ok(Screening {
387            levels: self.levels,
388            step: delta,
389            effects: self
390                .factors
391                .iter()
392                .zip(&effects)
393                .map(|(factor, ds)| moments(factor.name(), ds, true))
394                .collect(),
395        })
396    }
397}
398
399/// `Δ = p / (2 (p − 1))`.
400fn step(levels: usize) -> f64 {
401    // Cast: a number of levels is far below 2⁵³.
402    (levels / 2) as f64 / (levels - 1) as f64
403}
404
405/// Grid level `level` of `levels` on `[0, 1]`.
406fn unit(level: usize, levels: usize) -> f64 {
407    // Cast: a number of levels is far below 2⁵³.
408    level as f64 / (levels - 1) as f64
409}
410
411/// A whole number in `0..n`, from one uniform deviate.
412fn pick(rng: &mut SeededRng, n: usize) -> usize {
413    // Cast: `n` is far below 2⁵³, and `u < 1` keeps the product below `n`; `min` states it.
414    ((rng.uniform() * n as f64) as usize).min(n - 1)
415}
416
417/// The moments of a factor's effects `ds`: from paths (`sample`, with `n − 1` and a standard
418/// error), or the whole grid (with `n`, exact).
419fn moments(name: &str, ds: &[f64], sample: bool) -> ElementaryEffects {
420    // Cast: a count of effects is far below 2⁵³.
421    let n = ds.len() as f64;
422    let mean = ds.iter().sum::<f64>() / n;
423    let mean_absolute = ds.iter().map(|d| d.abs()).sum::<f64>() / n;
424    let squares = |center: f64, abs: bool| {
425        ds.iter()
426            .map(|&d| {
427                let e = if abs { d.abs() } else { d } - center;
428                e * e
429            })
430            .sum::<f64>()
431    };
432    let (standard_deviation, mean_absolute_standard_error) = if sample {
433        (
434            (squares(mean, false) / (n - 1.0)).sqrt(),
435            (squares(mean_absolute, true) / (n - 1.0) / n).sqrt(),
436        )
437    } else {
438        ((squares(mean, false) / n).sqrt(), 0.0)
439    };
440    ElementaryEffects {
441        name: name.to_owned(),
442        count: ds.len(),
443        mean,
444        mean_absolute,
445        standard_deviation,
446        mean_absolute_standard_error,
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    fn unit_factors(k: usize) -> Vec<Factor> {
455        (0..k)
456            .map(|i| Factor::new(format!("x{i}"), 0.0, 1.0).unwrap())
457            .collect()
458    }
459
460    #[test]
461    fn the_step_is_half_the_levels() {
462        let m = Morris::new(unit_factors(2), 4, 2).unwrap();
463        assert_eq!(m.step(), 2.0 / 3.0);
464        assert_eq!(Morris::new(unit_factors(2), 2, 2).unwrap().step(), 1.0);
465        assert_eq!(
466            Morris::new(unit_factors(2), 8, 2).unwrap().step(),
467            4.0 / 7.0
468        );
469    }
470
471    #[test]
472    fn a_screening_refuses_what_it_cant_lay_out() {
473        assert!(matches!(
474            Morris::new(Vec::new(), 4, 10),
475            Err(AnalysisError::TooFew {
476                what: "factors",
477                ..
478            })
479        ));
480        assert!(matches!(
481            Morris::new(unit_factors(2), 0, 10),
482            Err(AnalysisError::TooFew {
483                what: "grid levels",
484                count: 0,
485                ..
486            })
487        ));
488        match Morris::new(unit_factors(2), 5, 10) {
489            Err(AnalysisError::Unsupported(why)) => assert!(why.starts_with("5 grid levels")),
490            other => panic!("{other:?}"),
491        }
492        assert!(matches!(
493            Morris::new(unit_factors(2), MAX_LEVELS + 2, 10),
494            Err(AnalysisError::Count {
495                what: "grid levels",
496                limit: MAX_LEVELS,
497                ..
498            })
499        ));
500        assert!(Morris::new(unit_factors(2), MAX_LEVELS, 10).is_ok());
501        assert!(matches!(
502            Morris::new(unit_factors(2), 4, 1),
503            Err(AnalysisError::TooFew {
504                what: "Morris paths",
505                count: 1,
506                ..
507            })
508        ));
509        // Points past the limit: refused when laid out, not when allocated.
510        assert!(matches!(
511            Morris::new(unit_factors(1), 4, 1 << 62),
512            Err(AnalysisError::Count {
513                what: "Morris points",
514                limit: crate::sensitivity::MAX_DESIGN_POINTS,
515                ..
516            })
517        ));
518        assert!(Morris::new(unit_factors(1), 4, 1 << 19).is_ok());
519        assert!(matches!(
520            Morris::new(unit_factors(2), 4, usize::MAX / 2),
521            Err(AnalysisError::Count {
522                what: "Morris points",
523                count: usize::MAX,
524                ..
525            })
526        ));
527    }
528
529    #[test]
530    fn every_path_steps_each_factor_once_by_delta_and_stays_on_the_grid() {
531        let m = Morris::new(unit_factors(5), 6, 40).unwrap();
532        let design = m.design(7);
533        let points = design.points();
534        assert_eq!(points.len(), 40 * 6);
535        for (path, chunk) in design.paths().iter().zip(points.as_chunks::<6>().0) {
536            let mut order = path.order.clone();
537            order.sort_unstable();
538            assert_eq!(order, (0..5).collect::<Vec<_>>());
539            for (s, &i) in path.order.iter().enumerate() {
540                for (j, (after, before)) in chunk[s + 1].iter().zip(&chunk[s]).enumerate() {
541                    let moved = after - before;
542                    if j == i {
543                        let expected = if path.up[i] { 0.6 } else { -0.6 };
544                        assert!((moved - expected).abs() < 1e-15, "{moved}");
545                    } else {
546                        assert_eq!(moved, 0.0);
547                    }
548                }
549            }
550            for x in chunk.iter().flatten() {
551                assert!((0.0..=1.0).contains(x));
552                let level = x * 5.0;
553                assert!((level - level.round()).abs() < 1e-12);
554            }
555        }
556    }
557
558    #[test]
559    fn path_j_is_the_same_however_many_are_drawn() {
560        let short = Morris::new(unit_factors(4), 4, 3).unwrap().design(11);
561        let long = Morris::new(unit_factors(4), 4, 30).unwrap().design(11);
562        assert_eq!(short.paths(), &long.paths()[..3]);
563        assert_ne!(long.paths()[3], long.paths()[4]);
564        let other = Morris::new(unit_factors(4), 4, 3).unwrap().design(12);
565        assert_ne!(short.paths(), other.paths());
566    }
567
568    #[test]
569    fn a_linear_model_has_its_slopes_as_every_effect() {
570        // y = 3 a − 2 b + 0 c, with b's range 4 wide: its effects are its slope times its range.
571        let factors = vec![
572            Factor::new("a", 0.0, 1.0).unwrap(),
573            Factor::new("b", -1.0, 3.0).unwrap(),
574            Factor::new("c", 5.0, 6.0).unwrap(),
575        ];
576        let screening = Morris::new(factors, 4, 12)
577            .unwrap()
578            .screen(3, |x| 3.0 * x[0] - 2.0 * x[1])
579            .unwrap();
580        let expected = [3.0, -8.0, 0.0];
581        for (e, want) in screening.effects.iter().zip(expected) {
582            assert_eq!(e.count, 12);
583            assert!((e.mean - want).abs() < 1e-12, "{e:?}");
584            assert!((e.mean_absolute - want.abs()).abs() < 1e-12, "{e:?}");
585            assert!(e.standard_deviation < 1e-12, "{e:?}");
586            assert!(e.mean_absolute_standard_error < 1e-12, "{e:?}");
587        }
588    }
589
590    #[test]
591    fn a_step_down_gives_the_effect_at_the_lower_point() {
592        // y = x² on [0, 1], two levels: Δ = 1 and the one effect is y(1) − y(0) = 1, both ways.
593        let m = Morris::new(unit_factors(1), 2, 6).unwrap();
594        let design = m.design(1);
595        assert!(design.paths().iter().any(|p| p.up[0]));
596        assert!(design.paths().iter().any(|p| !p.up[0]));
597        let outputs: Vec<f64> = design.points().iter().map(|x| x[0] * x[0]).collect();
598        let e = &design.analyze(&outputs).unwrap().effects[0];
599        assert_eq!(
600            (e.mean, e.mean_absolute, e.standard_deviation),
601            (1.0, 1.0, 0.0)
602        );
603    }
604
605    #[test]
606    fn the_population_of_a_product_is_its_closed_form() {
607        // y = x₀ x₁ on [0, 1]², p = 4, Δ = 2/3: d₀ = x₁ at x₁'s four levels, each twice.
608        let m = Morris::new(unit_factors(2), 4, 2).unwrap();
609        let population = m.population(|x| x[0] * x[1]).unwrap();
610        let levels = [0.0, 1.0 / 3.0, 2.0 / 3.0, 1.0];
611        let mean = levels.iter().sum::<f64>() / 4.0;
612        let variance = levels.iter().map(|l| (l - mean).powi(2)).sum::<f64>() / 4.0;
613        for e in &population {
614            assert_eq!(e.count, 8);
615            assert!((e.mean - mean).abs() < 1e-15);
616            assert!((e.mean_absolute - mean).abs() < 1e-15);
617            assert!((e.standard_deviation - variance.sqrt()).abs() < 1e-15);
618            assert_eq!(e.mean_absolute_standard_error, 0.0);
619        }
620    }
621
622    #[test]
623    fn the_population_refuses_a_grid_too_large_and_names_a_bad_point() {
624        let m = Morris::new(unit_factors(13), 4, 2).unwrap();
625        assert!(matches!(
626            m.population(|_| 0.0),
627            Err(AnalysisError::Count {
628                what: "Morris grid points",
629                count: 67_108_864,
630                limit: MAX_POPULATION_POINTS,
631            })
632        ));
633        let m = Morris::new(unit_factors(2), 4, 2).unwrap();
634        // Index 6 is x₀ at level 2 and x₁ at level 1.
635        match m.population(|x| {
636            if x == [2.0 / 3.0, 1.0 / 3.0] {
637                f64::NAN
638            } else {
639                0.0
640            }
641        }) {
642            Err(AnalysisError::Output { index, .. }) => assert_eq!(index, 6),
643            other => panic!("{other:?}"),
644        }
645    }
646
647    #[test]
648    fn a_screening_reads_back_through_its_checks() {
649        let m = Morris::new(unit_factors(2), 4, 10).unwrap();
650        let json = serde_json::to_string(&m).unwrap();
651        assert_eq!(serde_json::from_str::<Morris>(&json).unwrap(), m);
652        let odd = json.replace("\"levels\":4", "\"levels\":3");
653        let refused = serde_json::from_str::<Morris>(&odd)
654            .unwrap_err()
655            .to_string();
656        assert!(refused.contains("3 grid levels"), "{refused}");
657    }
658}