Skip to main content

hpr_analysis/sensitivity/
sobol.rs

1//! Sobol' indices: the share of the output's variance each factor causes, alone and in all.
2//!
3//! **Guide:** [Sensitivity analysis][guide]'s *Sobol' indices* section.
4//!
5//! [guide]: https://nrdptel.github.io/hpr-sim/sensitivity.html#sobol-indices
6//!
7//! # The indices
8//!
9//! With the factors independent, the output's variance `V = Var(Y)` splits into the parts each
10//! factor causes alone, each pair together, and so on. Factor `i`'s *first-order* index is the
11//! share it causes alone, `Sᵢ = Var(E[Y | Xᵢ]) / V`; its *total* index is the share it has any
12//! part in, `S_Tᵢ = E[Var(Y | X₋ᵢ)] / V = 1 − Var(E[Y | X₋ᵢ]) / V`, with `X₋ᵢ` every factor but
13//! `i`. `Sᵢ ≤ S_Tᵢ`, and the gap is the share of `i`'s interactions with the others. A factor
14//! whose total index is near zero can be left at its nominal value. I. M. Sobol', "Global
15//! sensitivity indices for nonlinear mathematical models and their Monte Carlo estimates",
16//! *Mathematics and Computers in Simulation* 55, 271–280, 2001,
17//! <https://doi.org/10.1016/S0378-4754(00)00270-6>, defines both.
18//!
19//! # The estimates
20//!
21//! Two independent samples of `N` rows, `A` and `B`, each row every factor drawn uniformly over
22//! its range, and for each factor `i` a third, `A_B⁽ⁱ⁾`: `A` with its column `i` taken from `B`.
23//! The model runs at each row of all `k + 2`, `N (k + 2)` runs in all, and with `f` its output
24//!
25//! - `Vᵢ ≈ (1/N) Σⱼ f(B)ⱼ (f(A_B⁽ⁱ⁾)ⱼ − f(A)ⱼ)`, and
26//! - `V_Tᵢ ≈ (1/(2N)) Σⱼ (f(A)ⱼ − f(A_B⁽ⁱ⁾)ⱼ)²` (Jansen's),
27//!
28//! with `V` the variance of the `2N` outputs of `A` and `B` together, so `Sᵢ = Vᵢ/V` and
29//! `S_Tᵢ = V_Tᵢ/V`. Taking `V` from both samples, not `A`'s alone, is more accurate (A. Saltelli
30//! and others, *Global Sensitivity Analysis: The Primer*, Wiley, 2008, p. 166).
31//!
32//! A. Saltelli, P. Annoni, I. Azzini, F. Campolongo, M. Ratto and S. Tarantola, "Variance based
33//! sensitivity analysis of model output. Design and estimator for the total sensitivity index",
34//! *Computer Physics Communications* 181, 259–270, 2010,
35//! <https://doi.org/10.1016/j.cpc.2009.09.018>, give both (their Table 2, p. 262, rows (b) and
36//! (f)). They call Jansen's the best practice so far for the total index (p. 262), and recommend
37//! (b) for the first order, as its design holds more useful points (p. 263).
38//!
39//! The outputs are first shifted by their mean over `A` and `B`. That changes no index. Sobol'
40//! (2001, p. 277, remark 3) advises it against a loss of accuracy when the mean is large; it also
41//! keeps the first-order estimate's own variance, which has a term in the mean squared, from
42//! growing with the output's mean, as an apogee's would.
43//!
44//! # Sampling error
45//!
46//! Each row `j` is drawn independently, so every estimate is a smooth function of means over
47//! rows, and its standard error follows from the central limit theorem by the delta method: with
48//! `ψⱼ` row `j`'s first-order change to the estimate (its *influence*), the standard error is
49//! `√(Σⱼ ψⱼ² / (N (N − 1)))`. For `Sᵢ = P/V`, with `pⱼ` row `j`'s term of `Vᵢ`, `qⱼ` and `mⱼ` the
50//! means of its two outputs' squares and of the two outputs, and `D` the mean of
51//! `f(A_B⁽ⁱ⁾) − f(A)` (the shift's effect),
52//!
53//! `ψⱼ = ((pⱼ − P) − D (mⱼ − M)) / V − Sᵢ ((qⱼ − Q) − 2 M (mⱼ − M)) / V`,
54//!
55//! and the same for `S_Tᵢ` with Jansen's terms and no `D`. A unit test checks it against the
56//! gradient of each index over the raw row means, and the tests check, over 1,000 seeds of
57//! Ishigami's function and 500 of the g function, that it is the spread the estimates really
58//! have.
59
60use hpr_core::random::SeededRng;
61use serde::{Deserialize, Serialize};
62
63use super::{Factor, check_factors, check_outputs, check_size};
64use crate::error::AnalysisError;
65
66/// A Sobol' analysis: the factors and the number of rows `N` of each of the samples `A` and `B`.
67/// It serializes as its fields, and reads back through [`Sobol::new`]'s checks.
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69#[serde(try_from = "SobolData")]
70pub struct Sobol {
71    factors: Vec<Factor>,
72    rows: usize,
73}
74
75/// The serialized form of a [`Sobol`].
76#[derive(Deserialize)]
77#[serde(deny_unknown_fields)]
78struct SobolData {
79    factors: Vec<Factor>,
80    rows: usize,
81}
82
83impl TryFrom<SobolData> for Sobol {
84    type Error = AnalysisError;
85
86    fn try_from(data: SobolData) -> Result<Self, AnalysisError> {
87        Self::new(data.factors, data.rows)
88    }
89}
90
91/// The points of a Sobol' analysis, row after row: `A`'s row, `B`'s, then `A_B⁽ⁱ⁾`'s for each
92/// factor `i` in order, `k + 2` points a row. It is not serialized: [`Sobol::design`] rebuilds
93/// it, bit for bit, from the analysis and its seed.
94#[derive(Debug, Clone, PartialEq)]
95pub struct SobolDesign {
96    factors: Vec<Factor>,
97    /// `A`'s rows then `B`'s, each `k` fractions of the factors' ranges.
98    a: Vec<f64>,
99    b: Vec<f64>,
100}
101
102/// A factor's indices and their standard errors.
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104#[non_exhaustive]
105pub struct SobolIndex {
106    /// The factor's name.
107    pub name: String,
108    /// Its first-order index, `Sᵢ`.
109    pub first_order: f64,
110    /// The standard error of `Sᵢ`.
111    pub first_order_standard_error: f64,
112    /// Its total index, `S_Tᵢ`.
113    pub total: f64,
114    /// The standard error of `S_Tᵢ`.
115    pub total_standard_error: f64,
116}
117
118/// What a Sobol' analysis found.
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
120#[non_exhaustive]
121pub struct SobolIndices {
122    /// The rows `N` of each sample.
123    pub rows: usize,
124    /// The mean output over `A` and `B`.
125    pub mean: f64,
126    /// The output's variance over `A` and `B`, `V`, with `2N` in the denominator.
127    pub variance: f64,
128    /// Each factor's indices, in the factors' order.
129    pub factors: Vec<SobolIndex>,
130}
131
132impl Sobol {
133    /// An analysis of `factors` with `rows` rows in each of `A` and `B`.
134    ///
135    /// # Errors
136    ///
137    /// - [`AnalysisError::TooFew`] with no factors, or fewer than 2 rows (a standard error needs
138    ///   two).
139    /// - [`AnalysisError::DuplicateFactor`] for two factors with one name.
140    /// - [`AnalysisError::Count`] for a design of more than
141    ///   [`MAX_DESIGN_POINTS`](super::MAX_DESIGN_POINTS) points or
142    ///   [`MAX_DESIGN_VALUES`](super::MAX_DESIGN_VALUES) coordinates.
143    pub fn new(factors: Vec<Factor>, rows: usize) -> Result<Self, AnalysisError> {
144        check_factors(&factors)?;
145        if rows < 2 {
146            return Err(AnalysisError::TooFew {
147                what: "Sobol' rows",
148                count: rows,
149                minimum: 2,
150            });
151        }
152        let k = factors.len();
153        check_size("Sobol' points", rows, k.saturating_add(2), k)?;
154        Ok(Self { factors, rows })
155    }
156
157    /// The factors.
158    pub fn factors(&self) -> &[Factor] {
159        &self.factors
160    }
161
162    /// The rows `N` of each sample.
163    pub fn rows(&self) -> usize {
164        self.rows
165    }
166
167    /// The design seeded with `seed`. Row `j` draws from its own stream,
168    /// [`SeededRng::for_stream`]`(seed, &[j])`, `k` uniform deviates for `A`'s row and then `k`
169    /// for `B`'s, so it is the same however many rows are drawn.
170    pub fn design(&self, seed: u64) -> SobolDesign {
171        let k = self.factors.len();
172        let mut a = Vec::with_capacity(self.rows * k);
173        let mut b = Vec::with_capacity(self.rows * k);
174        for j in 0..self.rows {
175            // Cast: a row's index is far below 2⁶⁴.
176            let mut rng = SeededRng::for_stream(seed, &[j as u64]);
177            a.extend((0..k).map(|_| rng.uniform()));
178            b.extend((0..k).map(|_| rng.uniform()));
179        }
180        SobolDesign {
181            factors: self.factors.clone(),
182            a,
183            b,
184        }
185    }
186
187    /// Draws the design seeded with `seed`, runs `model` at each of its points in order, and
188    /// analyses the outputs ([`SobolDesign::analyze`]).
189    ///
190    /// # Errors
191    ///
192    /// As [`SobolDesign::analyze`].
193    pub fn indices(
194        &self,
195        seed: u64,
196        mut model: impl FnMut(&[f64]) -> f64,
197    ) -> Result<SobolIndices, AnalysisError> {
198        let design = self.design(seed);
199        let outputs: Vec<f64> = design.points().iter().map(|x| model(x)).collect();
200        design.analyze(&outputs)
201    }
202}
203
204impl SobolDesign {
205    /// The rows `N` of each sample.
206    pub fn rows(&self) -> usize {
207        self.a.len() / self.factors.len()
208    }
209
210    /// The number of points, `N (k + 2)`.
211    pub fn len(&self) -> usize {
212        self.rows() * (self.factors.len() + 2)
213    }
214
215    /// Whether there are no points; never, as an analysis has at least two rows.
216    pub fn is_empty(&self) -> bool {
217        self.len() == 0
218    }
219
220    /// The points, in physical units, row after row: `A`'s, `B`'s, then `A_B⁽ⁱ⁾`'s for each
221    /// factor `i`.
222    pub fn points(&self) -> Vec<Vec<f64>> {
223        let k = self.factors.len();
224        let physical = |unit: &[f64]| -> Vec<f64> {
225            unit.iter()
226                .zip(&self.factors)
227                .map(|(&u, factor)| factor.at(u))
228                .collect()
229        };
230        let mut points = Vec::with_capacity(self.len());
231        for (a, b) in self.a.chunks_exact(k).zip(self.b.chunks_exact(k)) {
232            let a = physical(a);
233            let b = physical(b);
234            for i in 0..k {
235                let mut mixed = a.clone();
236                mixed[i] = b[i];
237                points.push(mixed);
238            }
239            // `A`'s and `B`'s rows go first: rotate them in front of the mixed ones.
240            let first = points.len() - k;
241            points.push(a);
242            points.push(b);
243            points[first..].rotate_right(2);
244        }
245        points
246    }
247
248    /// Each factor's indices from `outputs`, the model's output at each of
249    /// [`SobolDesign::points`] in order (the module's docs give the estimates).
250    ///
251    /// # Errors
252    ///
253    /// - [`AnalysisError::Length`] unless there is one output per point.
254    /// - [`AnalysisError::Output`] for an output that isn't finite.
255    /// - [`AnalysisError::Domain`] if the outputs of `A` and `B` are all the same, as with no
256    ///   variance there is nothing to share out, or so spread that their variance overflows; or
257    ///   if an index or its standard error overflows, from a mixed point's output far beyond the
258    ///   others.
259    pub fn analyze(&self, outputs: &[f64]) -> Result<SobolIndices, AnalysisError> {
260        check_outputs(outputs, self.len())?;
261        let k = self.factors.len();
262        let rows: Vec<&[f64]> = outputs.chunks_exact(k + 2).collect();
263        // Cast: a count of rows is far below 2⁵³.
264        let n = rows.len() as f64;
265        let shift = rows.iter().map(|r| r[0] + r[1]).sum::<f64>() / (2.0 * n);
266        // Rows of shifted outputs: `A`'s, `B`'s, then the mixed ones.
267        let rows: Vec<Vec<f64>> = rows
268            .iter()
269            .map(|r| r.iter().map(|y| y - shift).collect())
270            .collect();
271        let m: Vec<f64> = rows.iter().map(|r| 0.5 * (r[0] + r[1])).collect();
272        let q: Vec<f64> = rows
273            .iter()
274            .map(|r| 0.5 * (r[0] * r[0] + r[1] * r[1]))
275            .collect();
276        let mean = |xs: &[f64]| xs.iter().sum::<f64>() / n;
277        let big_m = mean(&m);
278        let big_q = mean(&q);
279        let variance = big_q - big_m * big_m;
280        if !variance.is_finite() || variance <= 0.0 {
281            return Err(AnalysisError::Domain {
282                what: "output variance over A and B",
283                value: variance,
284            });
285        }
286        // Row j's influence on V, times −1/V: shared by every index.
287        let on_variance: Vec<f64> = m
288            .iter()
289            .zip(&q)
290            .map(|(&mj, &qj)| ((qj - big_q) - 2.0 * big_m * (mj - big_m)) / variance)
291            .collect();
292        let standard_error = |psi: &mut dyn Iterator<Item = f64>| {
293            (psi.map(|x| x * x).sum::<f64>() / (n * (n - 1.0))).sqrt()
294        };
295        let factors: Vec<SobolIndex> = self
296            .factors
297            .iter()
298            .enumerate()
299            .map(|(i, factor)| {
300                let first: Vec<f64> = rows.iter().map(|r| r[1] * (r[2 + i] - r[0])).collect();
301                let differences: Vec<f64> = rows.iter().map(|r| r[2 + i] - r[0]).collect();
302                let total: Vec<f64> = differences.iter().map(|d| 0.5 * d * d).collect();
303                let big_p = mean(&first);
304                let big_d = mean(&differences);
305                let big_t = mean(&total);
306                let s = big_p / variance;
307                let st = big_t / variance;
308                let first_order_standard_error =
309                    standard_error(&mut first.iter().zip(&m).zip(&on_variance).map(
310                        |((&pj, &mj), &v)| ((pj - big_p) - big_d * (mj - big_m)) / variance - s * v,
311                    ));
312                let total_standard_error = standard_error(
313                    &mut total
314                        .iter()
315                        .zip(&on_variance)
316                        .map(|(&tj, &v)| (tj - big_t) / variance - st * v),
317                );
318                SobolIndex {
319                    name: factor.name().to_owned(),
320                    first_order: s,
321                    first_order_standard_error,
322                    total: st,
323                    total_standard_error,
324                }
325            })
326            .collect();
327        if let Some(value) = factors
328            .iter()
329            .flat_map(|f| {
330                [
331                    f.first_order,
332                    f.first_order_standard_error,
333                    f.total,
334                    f.total_standard_error,
335                ]
336            })
337            .find(|x| !x.is_finite())
338        {
339            return Err(AnalysisError::Domain {
340                what: "Sobol' index or standard error (an output too large)",
341                value,
342            });
343        }
344        Ok(SobolIndices {
345            rows: rows.len(),
346            mean: shift + big_m,
347            variance,
348            factors,
349        })
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    fn unit_factors(k: usize) -> Vec<Factor> {
358        (0..k)
359            .map(|i| Factor::new(format!("x{i}"), 0.0, 1.0).unwrap())
360            .collect()
361    }
362
363    #[test]
364    fn an_analysis_refuses_what_it_cant_lay_out() {
365        assert!(matches!(
366            Sobol::new(Vec::new(), 10),
367            Err(AnalysisError::TooFew {
368                what: "factors",
369                ..
370            })
371        ));
372        assert!(matches!(
373            Sobol::new(unit_factors(2), 1),
374            Err(AnalysisError::TooFew {
375                what: "Sobol' rows",
376                count: 1,
377                ..
378            })
379        ));
380        assert!(matches!(
381            Sobol::new(unit_factors(1), 1 << 61),
382            Err(AnalysisError::Count {
383                what: "Sobol' points",
384                limit: crate::sensitivity::MAX_DESIGN_POINTS,
385                ..
386            })
387        ));
388        // 100 factors, 10,000 rows: 1,020,000 points, but 102,000,000 coordinates.
389        assert!(matches!(
390            Sobol::new(unit_factors(100), 10_000),
391            Err(AnalysisError::Count {
392                what: "design coordinates (points times factors)",
393                count: 102_000_000,
394                ..
395            })
396        ));
397        let huge = r#"{"factors":[{"name":"x","low":0.0,"high":1.0}],"rows":2305843009213693952}"#;
398        let refused = serde_json::from_str::<Sobol>(huge).unwrap_err().to_string();
399        assert!(refused.contains("Sobol' points"), "{refused}");
400    }
401
402    #[test]
403    fn each_row_holds_a_b_and_a_with_one_column_of_b() {
404        let factors = vec![
405            Factor::new("a", 0.0, 1.0).unwrap(),
406            Factor::new("b", 10.0, 20.0).unwrap(),
407            Factor::new("c", -1.0, 1.0).unwrap(),
408        ];
409        let design = Sobol::new(factors.clone(), 6).unwrap().design(5);
410        let points = design.points();
411        assert_eq!((points.len(), design.len(), design.rows()), (30, 30, 6));
412        for row in points.as_chunks::<5>().0 {
413            let (a, b) = (&row[0], &row[1]);
414            for (x, f) in a.iter().chain(b).zip(factors.iter().cycle()) {
415                assert!(*x >= f.low() && *x < f.high());
416            }
417            assert!(a.iter().zip(b).all(|(x, y)| x != y));
418            for (i, mixed) in row[2..].iter().enumerate() {
419                for j in 0..3 {
420                    assert_eq!(mixed[j], if i == j { b[j] } else { a[j] });
421                }
422            }
423        }
424    }
425
426    #[test]
427    fn row_j_is_the_same_however_many_are_drawn() {
428        let short = Sobol::new(unit_factors(3), 4).unwrap().design(9).points();
429        let long = Sobol::new(unit_factors(3), 40).unwrap().design(9).points();
430        assert_eq!(short[..], long[..short.len()]);
431        let other = Sobol::new(unit_factors(3), 4).unwrap().design(10).points();
432        assert_ne!(short, other);
433    }
434
435    #[test]
436    fn an_additive_model_has_equal_first_order_and_total_indices() {
437        // y = 2 x₀ + x₁ on [0, 1]²: shares 4/5 and 1/5, no interaction, so each pair of estimates
438        // is equal row by row only in expectation; both converge on the shares.
439        let indices = Sobol::new(unit_factors(2), 20_000)
440            .unwrap()
441            .indices(1, |x| 2.0 * x[0] + x[1])
442            .unwrap();
443        for (index, share) in indices.factors.iter().zip([0.8, 0.2]) {
444            for (estimate, error) in [
445                (index.first_order, index.first_order_standard_error),
446                (index.total, index.total_standard_error),
447            ] {
448                assert!(error > 0.0 && error < 0.02, "{index:?}");
449                assert!((estimate - share).abs() < 4.0 * error, "{index:?}");
450            }
451        }
452        assert!((indices.mean - 1.5).abs() < 0.02);
453        assert!((indices.variance - 5.0 / 12.0).abs() < 0.01);
454    }
455
456    #[test]
457    fn adding_a_constant_changes_no_estimate_beyond_rounding() {
458        let sobol = Sobol::new(unit_factors(2), 500).unwrap();
459        let f = |x: &[f64]| x[0] * x[1] + x[0];
460        let plain = sobol.indices(3, f).unwrap();
461        let shifted = sobol.indices(3, |x| f(x) + 1.0e4).unwrap();
462        for (a, b) in plain.factors.iter().zip(&shifted.factors) {
463            assert!((a.first_order - b.first_order).abs() < 1e-9);
464            assert!((a.first_order_standard_error - b.first_order_standard_error).abs() < 1e-9);
465            assert!((a.total - b.total).abs() < 1e-9);
466        }
467        assert!((shifted.mean - plain.mean - 1.0e4).abs() < 1e-8);
468    }
469
470    /// The standard errors by another route: the gradient of each index as a function of raw
471    /// (unshifted) row means, `Sᵢ = (R − M D)/(Q − M²)` with `R` the mean of `f(B)(f(A_B⁽ⁱ⁾) − f(A))`,
472    /// and `S_Tᵢ = T/(Q − M²)`, times the rows' sample covariance: `√(gᵀ Σ g / N)`.
473    #[test]
474    fn standard_errors_are_the_delta_method_on_raw_row_means() {
475        let f = |x: &[f64]| x[0] * x[1] + 2.0 * x[0] + x[2] * x[2] + 5.0;
476        let design = Sobol::new(unit_factors(3), 1000).unwrap().design(4);
477        let outputs: Vec<f64> = design.points().iter().map(|x| f(x)).collect();
478        let indices = design.analyze(&outputs).unwrap();
479        let rows: Vec<&[f64; 5]> = outputs.as_chunks::<5>().0.iter().collect();
480        let n = rows.len() as f64;
481        let mean = |v: &[f64]| v.iter().sum::<f64>() / n;
482        let covariance = |a: &[f64], b: &[f64]| {
483            let (ma, mb) = (mean(a), mean(b));
484            a.iter()
485                .zip(b)
486                .map(|(x, y)| (x - ma) * (y - mb))
487                .sum::<f64>()
488                / (n - 1.0)
489        };
490        // gᵀ Σ g / N over the columns `zs`.
491        let delta = |g: &[f64], zs: &[Vec<f64>]| {
492            let mut var = 0.0;
493            for (gi, zi) in g.iter().zip(zs) {
494                for (gj, zj) in g.iter().zip(zs) {
495                    var += gi * gj * covariance(zi, zj);
496                }
497            }
498            (var / n).sqrt()
499        };
500        let m: Vec<f64> = rows.iter().map(|r| 0.5 * (r[0] + r[1])).collect();
501        let q: Vec<f64> = rows
502            .iter()
503            .map(|r| 0.5 * (r[0] * r[0] + r[1] * r[1]))
504            .collect();
505        let (big_m, big_q) = (mean(&m), mean(&q));
506        let v = big_q - big_m * big_m;
507        assert!((v - indices.variance).abs() < 1e-12 * v);
508        for (i, index) in indices.factors.iter().enumerate() {
509            let d: Vec<f64> = rows.iter().map(|r| r[2 + i] - r[0]).collect();
510            let r: Vec<f64> = rows.iter().zip(&d).map(|(row, d)| row[1] * d).collect();
511            let t: Vec<f64> = d.iter().map(|d| 0.5 * d * d).collect();
512            let (big_r, big_d, big_t) = (mean(&r), mean(&d), mean(&t));
513            let s = (big_r - big_m * big_d) / v;
514            let st = big_t / v;
515            let g = [1.0 / v, -big_m / v, (2.0 * big_m * s - big_d) / v, -s / v];
516            let zs = [r, d.clone(), m.clone(), q.clone()];
517            let se = delta(&g, &zs);
518            let gt = [1.0 / v, 2.0 * big_m * st / v, -st / v];
519            let se_t = delta(&gt, &[t, m.clone(), q.clone()]);
520            let close = |x: f64, y: f64| (x - y).abs() < 1e-9 * y.abs();
521            assert!(close(index.first_order, s), "{index:?} against {s}");
522            assert!(close(index.total, st), "{index:?} against {st}");
523            assert!(
524                close(index.first_order_standard_error, se),
525                "{index:?} against {se}"
526            );
527            assert!(
528                close(index.total_standard_error, se_t),
529                "{index:?} against {se_t}"
530            );
531        }
532    }
533
534    #[test]
535    fn outputs_whose_variance_overflows_are_refused() {
536        let sobol = Sobol::new(unit_factors(2), 10).unwrap();
537        let large = sobol.indices(1, |x| 1e150 * x[0]).unwrap();
538        assert!(large.factors.iter().all(|f| f.first_order.is_finite()
539            && f.total.is_finite()
540            && f.first_order_standard_error.is_finite()
541            && f.total_standard_error.is_finite()));
542        match sobol.indices(1, |x| 1e160 * x[0]) {
543            Err(AnalysisError::Domain { what, value }) => {
544                assert_eq!(what, "output variance over A and B");
545                assert!(value.is_infinite());
546            }
547            other => panic!("{other:?}"),
548        }
549        // A and B ordinary, a mixed point's output huge: the variance is 1, the total overflows.
550        let design = Sobol::new(unit_factors(1), 2).unwrap().design(1);
551        match design.analyze(&[1.0, -1.0, 1e160, -1.0, 1.0, -1e160]) {
552            Err(AnalysisError::Domain { what, .. }) => {
553                assert_eq!(what, "Sobol' index or standard error (an output too large)");
554            }
555            other => panic!("{other:?}"),
556        }
557    }
558
559    #[test]
560    fn a_constant_output_is_refused() {
561        let sobol = Sobol::new(unit_factors(2), 10).unwrap();
562        assert!(matches!(
563            sobol.indices(1, |_| 3.0),
564            Err(AnalysisError::Domain {
565                what: "output variance over A and B",
566                ..
567            })
568        ));
569    }
570
571    #[test]
572    fn an_analysis_reads_back_through_its_checks() {
573        let s = Sobol::new(unit_factors(2), 64).unwrap();
574        let json = serde_json::to_string(&s).unwrap();
575        assert_eq!(serde_json::from_str::<Sobol>(&json).unwrap(), s);
576        let few = json.replace("\"rows\":64", "\"rows\":1");
577        let refused = serde_json::from_str::<Sobol>(&few).unwrap_err().to_string();
578        assert!(refused.contains("Sobol' rows: 1 given"), "{refused}");
579    }
580}