Skip to main content

fdars_core/
tolerance.rs

1//! Tolerance bands for functional data.
2//!
3//! This module provides methods for constructing regions expected to contain
4//! a given fraction of individual curves in a population — the functional
5//! analogue of classical tolerance intervals.
6//!
7//! # Methods
8//!
9//! - [`fpca_tolerance_band`] — FPCA + bootstrap tolerance band (pointwise or simultaneous)
10//! - [`conformal_prediction_band`] — Distribution-free conformal prediction band
11//! - [`scb_mean_degras`] — Simultaneous confidence band for the mean (Degras method)
12//! - [`exponential_family_tolerance_band`] — Tolerance band for exponential family data
13
14use crate::fdata::mean_1d;
15use crate::iter_maybe_parallel;
16use crate::matrix::FdMatrix;
17use crate::regression::fdata_to_pc_1d;
18use crate::smoothing::local_polynomial;
19use rand::prelude::*;
20use rand_distr::StandardNormal;
21#[cfg(feature = "parallel")]
22use rayon::iter::ParallelIterator;
23
24// ─── Types ──────────────────────────────────────────────────────────────────
25
26/// Result of a tolerance band computation.
27#[derive(Debug, Clone)]
28pub struct ToleranceBand {
29    /// Lower bound at each evaluation point
30    pub lower: Vec<f64>,
31    /// Upper bound at each evaluation point
32    pub upper: Vec<f64>,
33    /// Center function (typically the mean)
34    pub center: Vec<f64>,
35    /// Half-width at each evaluation point
36    pub half_width: Vec<f64>,
37}
38
39/// Type of tolerance band.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum BandType {
42    /// Independent interval at each evaluation point
43    Pointwise,
44    /// Single scaling factor across all points (wider, controls family-wise error)
45    Simultaneous,
46}
47
48/// Non-conformity score for conformal prediction.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum NonConformityScore {
51    /// Supremum norm: max_t |y(t) - center(t)|
52    SupNorm,
53    /// L2 norm: sqrt(sum (y(t) - center(t))^2)
54    L2,
55}
56
57/// Multiplier distribution for Degras SCB.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum MultiplierDistribution {
60    /// Standard normal multipliers
61    Gaussian,
62    /// Rademacher multipliers (+1/-1 with equal probability)
63    Rademacher,
64}
65
66/// Exponential family for generalized FPCA tolerance bands.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum ExponentialFamily {
69    /// Gaussian (identity link)
70    Gaussian,
71    /// Binomial (logit link)
72    Binomial,
73    /// Poisson (log link)
74    Poisson,
75}
76
77// ─── Private helpers ────────────────────────────────────────────────────────
78
79/// Column-wise mean and standard deviation.
80fn pointwise_mean_std(data: &FdMatrix) -> (Vec<f64>, Vec<f64>) {
81    let (n, m) = data.shape();
82    let nf = n as f64;
83    let mut means = vec![0.0; m];
84    let mut stds = vec![0.0; m];
85
86    for j in 0..m {
87        let col = data.column(j);
88        let mean = col.iter().sum::<f64>() / nf;
89        means[j] = mean;
90        let var = col.iter().map(|&v| (v - mean) * (v - mean)).sum::<f64>() / (nf - 1.0);
91        stds[j] = var.sqrt();
92    }
93    (means, stds)
94}
95
96/// Inverse normal CDF (probit) via rational approximation (Abramowitz & Stegun 26.2.23).
97fn normal_quantile(p: f64) -> f64 {
98    if p <= 0.0 || p >= 1.0 {
99        return f64::NAN;
100    }
101    if (p - 0.5).abs() < 1e-15 {
102        return 0.0;
103    }
104
105    // Use symmetry: for p < 0.5, compute for 1-p and negate
106    let (sign, q) = if p < 0.5 { (-1.0, 1.0 - p) } else { (1.0, p) };
107
108    let t = (-2.0 * (1.0 - q).ln()).sqrt();
109
110    // Rational approximation coefficients
111    const C0: f64 = 2.515517;
112    const C1: f64 = 0.802853;
113    const C2: f64 = 0.010328;
114    const D1: f64 = 1.432788;
115    const D2: f64 = 0.189269;
116    const D3: f64 = 0.001308;
117
118    let numerator = C0 + C1 * t + C2 * t * t;
119    let denominator = 1.0 + D1 * t + D2 * t * t + D3 * t * t * t;
120
121    sign * (t - numerator / denominator)
122}
123
124/// Construct a tolerance band from center and half-width vectors.
125fn build_band(center: Vec<f64>, half_width: Vec<f64>) -> ToleranceBand {
126    let lower: Vec<f64> = center
127        .iter()
128        .zip(half_width.iter())
129        .map(|(&c, &h)| c - h)
130        .collect();
131    let upper: Vec<f64> = center
132        .iter()
133        .zip(half_width.iter())
134        .map(|(&c, &h)| c + h)
135        .collect();
136    ToleranceBand {
137        lower,
138        upper,
139        center,
140        half_width,
141    }
142}
143
144/// Extract the percentile value from a sorted slice.
145fn percentile_sorted(sorted: &mut [f64], p: f64) -> f64 {
146    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
147    let idx = ((sorted.len() as f64 * p).ceil() as usize).min(sorted.len()) - 1;
148    sorted[idx]
149}
150
151/// Validate common tolerance band parameters. Returns false if any are invalid.
152fn valid_band_params(n: usize, m: usize, ncomp: usize, nb: usize, coverage: f64) -> bool {
153    n >= 3 && m > 0 && ncomp > 0 && nb > 0 && coverage > 0.0 && coverage < 1.0
154}
155
156// ─── FPCA + Bootstrap Tolerance Band ────────────────────────────────────────
157
158/// Per-component score statistics needed for bootstrap sampling.
159struct ScoreStats {
160    means: Vec<f64>,
161    stds: Vec<f64>,
162}
163
164/// Compute per-component score mean and std from FPCA results.
165fn compute_score_stats(scores: &FdMatrix, n: usize) -> ScoreStats {
166    let ncomp = scores.ncols();
167    let mut means = vec![0.0; ncomp];
168    let mut stds = vec![0.0; ncomp];
169    for k in 0..ncomp {
170        let col = scores.column(k);
171        let mean = col.iter().sum::<f64>() / n as f64;
172        means[k] = mean;
173        let var = col.iter().map(|&v| (v - mean) * (v - mean)).sum::<f64>() / (n as f64 - 1.0);
174        stds[k] = var.sqrt().max(1e-15);
175    }
176    ScoreStats { means, stds }
177}
178
179/// Sample a single bootstrap curve from FPCA model.
180fn sample_bootstrap_curve(
181    rng: &mut StdRng,
182    mean: &[f64],
183    rotation: &FdMatrix,
184    stats: &ScoreStats,
185    curve: &mut [f64],
186) {
187    let m = mean.len();
188    let ncomp = stats.means.len();
189    curve[..m].copy_from_slice(&mean[..m]);
190    for k in 0..ncomp {
191        let z: f64 = rng.sample(StandardNormal);
192        let score = stats.means[k] + stats.stds[k] * z;
193        for j in 0..m {
194            curve[j] += score * rotation[(j, k)];
195        }
196    }
197}
198
199/// Pointwise bootstrap: collect per-point std across bootstrap replicates.
200fn fpca_pointwise_boot(
201    fpca: &crate::regression::FpcaResult,
202    stats: &ScoreStats,
203    n: usize,
204    m: usize,
205    nb: usize,
206    coverage: f64,
207    seed: u64,
208) -> ToleranceBand {
209    let boot_stds: Vec<Vec<f64>> = iter_maybe_parallel!(0..nb)
210        .map(|b| {
211            let mut rng = StdRng::seed_from_u64(seed + b as u64);
212            let mut curve = vec![0.0; m];
213            let mut sum = vec![0.0; m];
214            let mut sum_sq = vec![0.0; m];
215            for _ in 0..n {
216                sample_bootstrap_curve(&mut rng, &fpca.mean, &fpca.rotation, stats, &mut curve);
217                for j in 0..m {
218                    sum[j] += curve[j];
219                    sum_sq[j] += curve[j] * curve[j];
220                }
221            }
222            let nf = n as f64;
223            (0..m)
224                .map(|j| (sum_sq[j] / nf - (sum[j] / nf).powi(2)).max(0.0).sqrt())
225                .collect::<Vec<f64>>()
226        })
227        .collect();
228
229    let z = normal_quantile((1.0 + coverage) / 2.0);
230    let center = fpca.mean.clone();
231    let mut half_width = vec![0.0; m];
232    for j in 0..m {
233        let mut stds_j: Vec<f64> = boot_stds.iter().map(|s| s[j]).collect();
234        let pct = percentile_sorted(&mut stds_j, coverage);
235        half_width[j] = z * pct;
236    }
237    build_band(center, half_width)
238}
239
240/// Simultaneous bootstrap: compute sup-norm scaling factor.
241fn fpca_simultaneous_boot(
242    data: &FdMatrix,
243    fpca: &crate::regression::FpcaResult,
244    stats: &ScoreStats,
245    n: usize,
246    m: usize,
247    nb: usize,
248    coverage: f64,
249    seed: u64,
250) -> ToleranceBand {
251    let (center, data_std) = pointwise_mean_std(data);
252
253    let mut sup_norms: Vec<f64> = iter_maybe_parallel!(0..nb)
254        .map(|b| {
255            let mut rng = StdRng::seed_from_u64(seed + b as u64);
256            let mut max_dev = 0.0_f64;
257            let mut curve = vec![0.0; m];
258            for _ in 0..n {
259                sample_bootstrap_curve(&mut rng, &fpca.mean, &fpca.rotation, stats, &mut curve);
260                let dev = (0..m)
261                    .map(|j| (curve[j] - center[j]).abs() / data_std[j].max(1e-15))
262                    .fold(0.0_f64, f64::max);
263                max_dev = max_dev.max(dev);
264            }
265            max_dev
266        })
267        .collect();
268
269    let k_factor = percentile_sorted(&mut sup_norms, coverage);
270    let half_width: Vec<f64> = data_std.iter().map(|&s| k_factor * s).collect();
271    build_band(center, half_width)
272}
273
274/// Compute a tolerance band via FPCA and bootstrap.
275///
276/// Decomposes the data using functional PCA, then uses a parametric bootstrap
277/// on PC scores to estimate the band width.
278///
279/// # Arguments
280/// * `data` — Functional data matrix (n observations × m evaluation points)
281/// * `ncomp` — Number of principal components to retain
282/// * `nb` — Number of bootstrap replicates
283/// * `coverage` — Target coverage probability (e.g., 0.95)
284/// * `band_type` — [`BandType::Pointwise`] or [`BandType::Simultaneous`]
285/// * `seed` — Random seed for reproducibility
286///
287/// # Returns
288/// `Some(ToleranceBand)` on success, `None` if inputs are invalid or FPCA fails.
289///
290/// # Examples
291///
292/// ```
293/// use fdars_core::simulation::{sim_fundata, EFunType, EValType};
294/// use fdars_core::tolerance::{fpca_tolerance_band, BandType};
295///
296/// let t: Vec<f64> = (0..50).map(|i| i as f64 / 49.0).collect();
297/// let data = sim_fundata(30, &t, 5, EFunType::Fourier, EValType::Exponential, Some(42));
298///
299/// let band = fpca_tolerance_band(&data, 3, 100, 0.95, BandType::Pointwise, 42).unwrap();
300/// assert_eq!(band.lower.len(), 50);
301/// assert!(band.lower.iter().zip(band.upper.iter()).all(|(l, u)| l < u));
302/// ```
303pub fn fpca_tolerance_band(
304    data: &FdMatrix,
305    ncomp: usize,
306    nb: usize,
307    coverage: f64,
308    band_type: BandType,
309    seed: u64,
310) -> Option<ToleranceBand> {
311    let (n, m) = data.shape();
312    if !valid_band_params(n, m, ncomp, nb, coverage) {
313        return None;
314    }
315
316    let fpca = fdata_to_pc_1d(data, ncomp)?;
317    let stats = compute_score_stats(&fpca.scores, n);
318
319    Some(match band_type {
320        BandType::Pointwise => fpca_pointwise_boot(&fpca, &stats, n, m, nb, coverage, seed),
321        BandType::Simultaneous => {
322            fpca_simultaneous_boot(data, &fpca, &stats, n, m, nb, coverage, seed)
323        }
324    })
325}
326
327// ─── Conformal Prediction Band ──────────────────────────────────────────────
328
329/// Compute a non-conformity score for a single curve against a center function.
330fn nonconformity_score(
331    data: &FdMatrix,
332    i: usize,
333    center: &[f64],
334    m: usize,
335    score_type: NonConformityScore,
336) -> f64 {
337    match score_type {
338        NonConformityScore::SupNorm => (0..m)
339            .map(|j| (data[(i, j)] - center[j]).abs())
340            .fold(0.0_f64, f64::max),
341        NonConformityScore::L2 => {
342            let ss: f64 = (0..m).map(|j| (data[(i, j)] - center[j]).powi(2)).sum();
343            ss.sqrt()
344        }
345    }
346}
347
348/// Build a subset matrix from selected row indices.
349fn subset_rows(data: &FdMatrix, indices: &[usize], m: usize) -> FdMatrix {
350    let n_sub = indices.len();
351    let mut sub = FdMatrix::zeros(n_sub, m);
352    for (new_i, &old_i) in indices.iter().enumerate() {
353        for j in 0..m {
354            sub[(new_i, j)] = data[(old_i, j)];
355        }
356    }
357    sub
358}
359
360/// Compute a distribution-free conformal prediction band.
361///
362/// Splits data into training and calibration sets, computes a non-conformity
363/// score on calibration curves, and uses the conformal quantile to build
364/// a prediction band.
365///
366/// # Arguments
367/// * `data` — Functional data matrix (n × m)
368/// * `cal_fraction` — Fraction of data used for calibration (e.g., 0.2)
369/// * `coverage` — Target coverage probability (e.g., 0.95)
370/// * `score_type` — [`NonConformityScore::SupNorm`] or [`NonConformityScore::L2`]
371/// * `seed` — Random seed for the train/calibration split
372///
373/// # Returns
374/// `Some(ToleranceBand)` on success, `None` if inputs are invalid.
375///
376/// # Examples
377///
378/// ```
379/// use fdars_core::simulation::{sim_fundata, EFunType, EValType};
380/// use fdars_core::tolerance::{conformal_prediction_band, NonConformityScore};
381///
382/// let t: Vec<f64> = (0..50).map(|i| i as f64 / 49.0).collect();
383/// let data = sim_fundata(40, &t, 5, EFunType::Fourier, EValType::Exponential, Some(42));
384///
385/// let band = conformal_prediction_band(&data, 0.2, 0.95, NonConformityScore::SupNorm, 42).unwrap();
386/// // SupNorm gives constant half-width across all evaluation points
387/// let hw = band.half_width[0];
388/// assert!(band.half_width.iter().all(|&h| (h - hw).abs() < 1e-12));
389/// ```
390pub fn conformal_prediction_band(
391    data: &FdMatrix,
392    cal_fraction: f64,
393    coverage: f64,
394    score_type: NonConformityScore,
395    seed: u64,
396) -> Option<ToleranceBand> {
397    let (n, m) = data.shape();
398    if n < 4
399        || m == 0
400        || cal_fraction <= 0.0
401        || cal_fraction >= 1.0
402        || coverage <= 0.0
403        || coverage >= 1.0
404    {
405        return None;
406    }
407
408    let n_cal = ((n as f64) * cal_fraction).max(1.0).min((n - 2) as f64) as usize;
409    let n_train = n - n_cal;
410
411    // Random permutation for split
412    let mut indices: Vec<usize> = (0..n).collect();
413    let mut rng = StdRng::seed_from_u64(seed);
414    indices.shuffle(&mut rng);
415
416    let train_data = subset_rows(data, &indices[..n_train], m);
417    let center = mean_1d(&train_data);
418
419    // Compute non-conformity scores on calibration set
420    let cal_idx = &indices[n_train..];
421    let mut scores: Vec<f64> = cal_idx
422        .iter()
423        .map(|&i| nonconformity_score(data, i, &center, m, score_type))
424        .collect();
425
426    // Conformal quantile: ceil((n_cal + 1) * coverage) / n_cal
427    let level = (((n_cal + 1) as f64 * coverage).ceil() / n_cal as f64).min(1.0);
428    let q = percentile_sorted(&mut scores, level);
429
430    // Build band depending on score type
431    let half_width = match score_type {
432        NonConformityScore::SupNorm => vec![q; m],
433        NonConformityScore::L2 => vec![q / (m as f64).sqrt(); m],
434    };
435
436    Some(build_band(center, half_width))
437}
438
439// ─── SCB for the Mean (Degras) ──────────────────────────────────────────────
440
441/// Compute pointwise residual standard deviation.
442fn residual_sigma(data: &FdMatrix, center: &[f64], n: usize, m: usize) -> Vec<f64> {
443    let nf = n as f64;
444    (0..m)
445        .map(|j| {
446            let var: f64 = (0..n).map(|i| (data[(i, j)] - center[j]).powi(2)).sum();
447            (var / nf).sqrt().max(1e-15)
448        })
449        .collect()
450}
451
452/// Generate n multiplier weights for Degras bootstrap.
453fn generate_multiplier_weights(
454    rng: &mut StdRng,
455    n: usize,
456    multiplier: MultiplierDistribution,
457) -> Vec<f64> {
458    (0..n)
459        .map(|_| match multiplier {
460            MultiplierDistribution::Gaussian => rng.sample::<f64, _>(StandardNormal),
461            MultiplierDistribution::Rademacher => {
462                if rng.gen_bool(0.5) {
463                    1.0
464                } else {
465                    -1.0
466                }
467            }
468        })
469        .collect()
470}
471
472/// Compute a simultaneous confidence band for the mean function (Degras method).
473///
474/// Uses a multiplier bootstrap to estimate the critical value for a
475/// simultaneous confidence band around the mean.
476///
477/// # Arguments
478/// * `data` — Functional data matrix (n × m)
479/// * `argvals` — Evaluation points (length m)
480/// * `bandwidth` — Kernel bandwidth for local polynomial smoothing
481/// * `nb` — Number of bootstrap replicates
482/// * `confidence` — Confidence level (e.g., 0.95)
483/// * `multiplier` — [`MultiplierDistribution::Gaussian`] or [`MultiplierDistribution::Rademacher`]
484///
485/// # Returns
486/// `Some(ToleranceBand)` on success, `None` if inputs are invalid.
487///
488/// # Examples
489///
490/// ```
491/// use fdars_core::simulation::{sim_fundata, EFunType, EValType};
492/// use fdars_core::tolerance::{scb_mean_degras, MultiplierDistribution};
493///
494/// let t: Vec<f64> = (0..50).map(|i| i as f64 / 49.0).collect();
495/// let data = sim_fundata(50, &t, 5, EFunType::Fourier, EValType::Exponential, Some(42));
496///
497/// let band = scb_mean_degras(&data, &t, 0.15, 200, 0.95, MultiplierDistribution::Gaussian).unwrap();
498/// assert_eq!(band.center.len(), 50);
499/// assert!(band.lower.iter().zip(band.upper.iter()).all(|(l, u)| l < u));
500/// ```
501pub fn scb_mean_degras(
502    data: &FdMatrix,
503    argvals: &[f64],
504    bandwidth: f64,
505    nb: usize,
506    confidence: f64,
507    multiplier: MultiplierDistribution,
508) -> Option<ToleranceBand> {
509    let (n, m) = data.shape();
510    if n < 3
511        || m == 0
512        || argvals.len() != m
513        || bandwidth <= 0.0
514        || nb == 0
515        || confidence <= 0.0
516        || confidence >= 1.0
517    {
518        return None;
519    }
520
521    let raw_mean = mean_1d(data);
522    let center = local_polynomial(argvals, &raw_mean, argvals, bandwidth, 1, "epanechnikov");
523    let sigma_hat = residual_sigma(data, &center, n, m);
524
525    let sqrt_n = (n as f64).sqrt();
526    let mut sup_stats: Vec<f64> = iter_maybe_parallel!(0..nb)
527        .map(|b| {
528            let mut rng = StdRng::seed_from_u64(42 + b as u64);
529            let weights = generate_multiplier_weights(&mut rng, n, multiplier);
530            (0..m)
531                .map(|j| {
532                    let z: f64 = (0..n)
533                        .map(|i| weights[i] * (data[(i, j)] - center[j]))
534                        .sum::<f64>()
535                        / (sqrt_n * sigma_hat[j]);
536                    z.abs()
537                })
538                .fold(0.0_f64, f64::max)
539        })
540        .collect();
541
542    let c = percentile_sorted(&mut sup_stats, confidence);
543    let half_width: Vec<f64> = sigma_hat.iter().map(|&s| c * s / sqrt_n).collect();
544
545    Some(build_band(center, half_width))
546}
547
548// ─── Exponential Family Tolerance Band ──────────────────────────────────────
549
550/// Apply the link function for an exponential family.
551fn apply_link(value: f64, family: ExponentialFamily) -> f64 {
552    match family {
553        ExponentialFamily::Gaussian => value,
554        ExponentialFamily::Binomial => {
555            // logit: log(p / (1-p)), clamp to avoid infinities
556            let p = value.clamp(1e-10, 1.0 - 1e-10);
557            (p / (1.0 - p)).ln()
558        }
559        ExponentialFamily::Poisson => {
560            // log link, clamp to avoid log(0)
561            value.max(1e-10).ln()
562        }
563    }
564}
565
566/// Apply the inverse link function for an exponential family.
567fn apply_inverse_link(value: f64, family: ExponentialFamily) -> f64 {
568    match family {
569        ExponentialFamily::Gaussian => value,
570        ExponentialFamily::Binomial => {
571            // inverse logit: 1 / (1 + exp(-x))
572            1.0 / (1.0 + (-value).exp())
573        }
574        ExponentialFamily::Poisson => {
575            // exp
576            value.exp()
577        }
578    }
579}
580
581/// Apply a link function element-wise to all data entries.
582fn transform_data(data: &FdMatrix, family: ExponentialFamily) -> FdMatrix {
583    let (n, m) = data.shape();
584    let mut out = FdMatrix::zeros(n, m);
585    for j in 0..m {
586        for i in 0..n {
587            out[(i, j)] = apply_link(data[(i, j)], family);
588        }
589    }
590    out
591}
592
593/// Apply the inverse link to a band, recomputing half-widths on the response scale.
594fn inverse_link_band(band: ToleranceBand, family: ExponentialFamily) -> ToleranceBand {
595    let lower: Vec<f64> = band
596        .lower
597        .iter()
598        .map(|&v| apply_inverse_link(v, family))
599        .collect();
600    let upper: Vec<f64> = band
601        .upper
602        .iter()
603        .map(|&v| apply_inverse_link(v, family))
604        .collect();
605    let center: Vec<f64> = band
606        .center
607        .iter()
608        .map(|&v| apply_inverse_link(v, family))
609        .collect();
610    let half_width: Vec<f64> = upper
611        .iter()
612        .zip(lower.iter())
613        .map(|(&u, &l)| (u - l) / 2.0)
614        .collect();
615    ToleranceBand {
616        lower,
617        upper,
618        center,
619        half_width,
620    }
621}
622
623/// Compute a tolerance band for exponential family functional data.
624///
625/// Transforms data via the canonical link function, applies FPCA + bootstrap
626/// on the transformed scale, then maps the band back via the inverse link.
627///
628/// # Arguments
629/// * `data` — Functional data matrix (n × m), values in natural parameter space
630/// * `family` — [`ExponentialFamily`] specifying the distribution
631/// * `ncomp` — Number of principal components to retain
632/// * `nb` — Number of bootstrap replicates
633/// * `coverage` — Target coverage probability (e.g., 0.95)
634/// * `seed` — Random seed for reproducibility
635///
636/// # Returns
637/// `Some(ToleranceBand)` on success, `None` if inputs are invalid.
638///
639/// # Examples
640///
641/// ```
642/// use fdars_core::matrix::FdMatrix;
643/// use fdars_core::simulation::{sim_fundata, EFunType, EValType};
644/// use fdars_core::tolerance::{exponential_family_tolerance_band, ExponentialFamily};
645///
646/// // Create positive data suitable for Poisson family
647/// let t: Vec<f64> = (0..30).map(|i| i as f64 / 29.0).collect();
648/// let raw = sim_fundata(30, &t, 3, EFunType::Fourier, EValType::Exponential, Some(42));
649/// let mut data = FdMatrix::zeros(30, 30);
650/// for j in 0..30 {
651///     for i in 0..30 {
652///         data[(i, j)] = (raw[(i, j)] + 5.0).max(0.1);
653///     }
654/// }
655///
656/// let band = exponential_family_tolerance_band(
657///     &data, ExponentialFamily::Poisson, 3, 50, 0.95, 42,
658/// ).unwrap();
659/// // Poisson inverse link (exp) ensures all bounds are positive
660/// assert!(band.lower.iter().all(|&v| v > 0.0));
661/// ```
662pub fn exponential_family_tolerance_band(
663    data: &FdMatrix,
664    family: ExponentialFamily,
665    ncomp: usize,
666    nb: usize,
667    coverage: f64,
668    seed: u64,
669) -> Option<ToleranceBand> {
670    let (n, m) = data.shape();
671    if !valid_band_params(n, m, ncomp, nb, coverage) {
672        return None;
673    }
674
675    let transformed = transform_data(data, family);
676    let band = fpca_tolerance_band(
677        &transformed,
678        ncomp,
679        nb,
680        coverage,
681        BandType::Simultaneous,
682        seed,
683    )?;
684    Some(inverse_link_band(band, family))
685}
686
687// ─── Elastic Tolerance Band ─────────────────────────────────────────────────
688
689/// Compute a tolerance band in the elastic (aligned) space.
690///
691/// First computes the Karcher mean to align all curves, then applies the
692/// FPCA tolerance band on the aligned data. This separates amplitude
693/// variability from phase variability, giving bands that reflect shape
694/// variation without contamination from timing differences.
695///
696/// # Arguments
697/// * `data` — Functional data matrix (n × m)
698/// * `argvals` — Evaluation points (length m)
699/// * `ncomp` — Number of principal components to retain
700/// * `nb` — Number of bootstrap replicates
701/// * `coverage` — Target coverage probability (e.g., 0.95)
702/// * `band_type` — [`BandType::Pointwise`] or [`BandType::Simultaneous`]
703/// * `max_iter` — Maximum iterations for Karcher mean convergence
704/// * `seed` — Random seed for reproducibility
705///
706/// # Returns
707/// `Some(ToleranceBand)` in the aligned space, `None` if inputs are invalid.
708///
709/// # Examples
710///
711/// ```
712/// use fdars_core::simulation::{sim_fundata, EFunType, EValType};
713/// use fdars_core::tolerance::{elastic_tolerance_band, BandType};
714///
715/// let t: Vec<f64> = (0..50).map(|i| i as f64 / 49.0).collect();
716/// let data = sim_fundata(30, &t, 5, EFunType::Fourier, EValType::Exponential, Some(42));
717///
718/// let band = elastic_tolerance_band(&data, &t, 3, 100, 0.95, BandType::Pointwise, 10, 42);
719/// assert!(band.is_some());
720/// ```
721pub fn elastic_tolerance_band(
722    data: &FdMatrix,
723    argvals: &[f64],
724    ncomp: usize,
725    nb: usize,
726    coverage: f64,
727    band_type: BandType,
728    max_iter: usize,
729    seed: u64,
730) -> Option<ToleranceBand> {
731    let (n, m) = data.shape();
732    if !valid_band_params(n, m, ncomp, nb, coverage) || argvals.len() != m || max_iter == 0 {
733        return None;
734    }
735
736    // Step 1: Karcher mean → aligned data
737    let karcher = crate::alignment::karcher_mean(data, argvals, max_iter, 1e-4);
738
739    // Step 2: FPCA tolerance band on aligned data
740    fpca_tolerance_band(&karcher.aligned_data, ncomp, nb, coverage, band_type, seed)
741}
742
743// ─── Tests ──────────────────────────────────────────────────────────────────
744
745#[cfg(test)]
746mod tests {
747    use super::*;
748    use crate::simulation::{sim_fundata, EFunType, EValType};
749
750    fn uniform_grid(m: usize) -> Vec<f64> {
751        (0..m).map(|i| i as f64 / (m - 1) as f64).collect()
752    }
753
754    fn make_test_data() -> FdMatrix {
755        let m = 50;
756        let t = uniform_grid(m);
757        sim_fundata(
758            50,
759            &t,
760            5,
761            EFunType::Fourier,
762            EValType::Exponential,
763            Some(42),
764        )
765    }
766
767    // ── normal_quantile tests ──
768
769    #[test]
770    fn test_normal_quantile_symmetry() {
771        for &p in &[0.1, 0.2, 0.3, 0.4] {
772            let q_low = normal_quantile(p);
773            let q_high = normal_quantile(1.0 - p);
774            assert!(
775                (q_low + q_high).abs() < 1e-6,
776                "q({p}) + q({}) = {} (expected ~0)",
777                1.0 - p,
778                q_low + q_high
779            );
780        }
781    }
782
783    #[test]
784    fn test_normal_quantile_known_values() {
785        let q975 = normal_quantile(0.975);
786        assert!(
787            (q975 - 1.96).abs() < 0.01,
788            "q(0.975) = {q975}, expected ~1.96"
789        );
790
791        let q50 = normal_quantile(0.5);
792        assert!(q50.abs() < 1e-10, "q(0.5) = {q50}, expected 0.0");
793
794        let q_invalid = normal_quantile(0.0);
795        assert!(q_invalid.is_nan());
796        let q_invalid2 = normal_quantile(1.0);
797        assert!(q_invalid2.is_nan());
798    }
799
800    // ── FPCA tolerance band tests ──
801
802    #[test]
803    fn test_fpca_band_valid_output() {
804        let data = make_test_data();
805        let m = data.ncols();
806
807        let band = fpca_tolerance_band(&data, 3, 100, 0.95, BandType::Pointwise, 42);
808        let band = band.expect("FPCA band should succeed");
809
810        assert_eq!(band.lower.len(), m);
811        assert_eq!(band.upper.len(), m);
812        assert_eq!(band.center.len(), m);
813        assert_eq!(band.half_width.len(), m);
814    }
815
816    #[test]
817    fn test_fpca_band_lower_less_than_upper() {
818        let data = make_test_data();
819        let band = fpca_tolerance_band(&data, 3, 100, 0.95, BandType::Pointwise, 42).unwrap();
820
821        for j in 0..band.lower.len() {
822            assert!(
823                band.lower[j] < band.upper[j],
824                "lower[{j}] = {} >= upper[{j}] = {}",
825                band.lower[j],
826                band.upper[j]
827            );
828        }
829    }
830
831    #[test]
832    fn test_fpca_band_deterministic() {
833        let data = make_test_data();
834        let b1 = fpca_tolerance_band(&data, 3, 50, 0.95, BandType::Pointwise, 123).unwrap();
835        let b2 = fpca_tolerance_band(&data, 3, 50, 0.95, BandType::Pointwise, 123).unwrap();
836
837        for j in 0..b1.lower.len() {
838            assert_eq!(b1.lower[j], b2.lower[j]);
839            assert_eq!(b1.upper[j], b2.upper[j]);
840        }
841    }
842
843    #[test]
844    fn test_fpca_simultaneous_wider_than_pointwise() {
845        let data = make_test_data();
846        let pw = fpca_tolerance_band(&data, 3, 200, 0.95, BandType::Pointwise, 42).unwrap();
847        let sim = fpca_tolerance_band(&data, 3, 200, 0.95, BandType::Simultaneous, 42).unwrap();
848
849        let pw_mean_hw: f64 = pw.half_width.iter().sum::<f64>() / pw.half_width.len() as f64;
850        let sim_mean_hw: f64 = sim.half_width.iter().sum::<f64>() / sim.half_width.len() as f64;
851
852        assert!(
853            sim_mean_hw > pw_mean_hw,
854            "Simultaneous mean half-width ({sim_mean_hw}) should exceed pointwise ({pw_mean_hw})"
855        );
856    }
857
858    #[test]
859    fn test_fpca_higher_coverage_wider() {
860        let data = make_test_data();
861        let b90 = fpca_tolerance_band(&data, 3, 200, 0.90, BandType::Pointwise, 42).unwrap();
862        let b99 = fpca_tolerance_band(&data, 3, 200, 0.99, BandType::Pointwise, 42).unwrap();
863
864        let hw90: f64 = b90.half_width.iter().sum::<f64>();
865        let hw99: f64 = b99.half_width.iter().sum::<f64>();
866
867        assert!(
868            hw99 > hw90,
869            "99% band total half-width ({hw99}) should exceed 90% ({hw90})"
870        );
871    }
872
873    #[test]
874    fn test_fpca_band_invalid_input() {
875        let data = make_test_data();
876        assert!(fpca_tolerance_band(&data, 0, 100, 0.95, BandType::Pointwise, 42).is_none());
877        assert!(fpca_tolerance_band(&data, 3, 0, 0.95, BandType::Pointwise, 42).is_none());
878        assert!(fpca_tolerance_band(&data, 3, 100, 0.0, BandType::Pointwise, 42).is_none());
879        assert!(fpca_tolerance_band(&data, 3, 100, 1.0, BandType::Pointwise, 42).is_none());
880
881        let tiny = FdMatrix::zeros(2, 5);
882        assert!(fpca_tolerance_band(&tiny, 1, 10, 0.95, BandType::Pointwise, 42).is_none());
883    }
884
885    // ── Conformal prediction band tests ──
886
887    #[test]
888    fn test_conformal_band_valid_output() {
889        let data = make_test_data();
890        let m = data.ncols();
891
892        let band = conformal_prediction_band(&data, 0.2, 0.95, NonConformityScore::SupNorm, 42);
893        let band = band.expect("Conformal band should succeed");
894
895        assert_eq!(band.lower.len(), m);
896        assert_eq!(band.upper.len(), m);
897    }
898
899    #[test]
900    fn test_conformal_supnorm_constant_width() {
901        let data = make_test_data();
902        let band =
903            conformal_prediction_band(&data, 0.3, 0.95, NonConformityScore::SupNorm, 42).unwrap();
904
905        let first = band.half_width[0];
906        for &hw in &band.half_width {
907            assert!(
908                (hw - first).abs() < 1e-12,
909                "SupNorm band should have constant width"
910            );
911        }
912    }
913
914    #[test]
915    fn test_conformal_l2_constant_width() {
916        let data = make_test_data();
917        let band = conformal_prediction_band(&data, 0.3, 0.95, NonConformityScore::L2, 42).unwrap();
918
919        let first = band.half_width[0];
920        for &hw in &band.half_width {
921            assert!(
922                (hw - first).abs() < 1e-12,
923                "L2 band should have constant width"
924            );
925        }
926    }
927
928    #[test]
929    fn test_conformal_coverage_monotonicity() {
930        let data = make_test_data();
931        let b80 =
932            conformal_prediction_band(&data, 0.3, 0.80, NonConformityScore::SupNorm, 42).unwrap();
933        let b95 =
934            conformal_prediction_band(&data, 0.3, 0.95, NonConformityScore::SupNorm, 42).unwrap();
935
936        assert!(
937            b95.half_width[0] >= b80.half_width[0],
938            "Higher coverage should give wider band"
939        );
940    }
941
942    #[test]
943    fn test_conformal_invalid_input() {
944        let data = make_test_data();
945        assert!(
946            conformal_prediction_band(&data, 0.0, 0.95, NonConformityScore::SupNorm, 42).is_none()
947        );
948        assert!(
949            conformal_prediction_band(&data, 1.0, 0.95, NonConformityScore::SupNorm, 42).is_none()
950        );
951        assert!(
952            conformal_prediction_band(&data, 0.2, 0.0, NonConformityScore::SupNorm, 42).is_none()
953        );
954
955        let tiny = FdMatrix::zeros(3, 5);
956        assert!(
957            conformal_prediction_band(&tiny, 0.2, 0.95, NonConformityScore::SupNorm, 42).is_none()
958        );
959    }
960
961    // ── SCB mean Degras tests ──
962
963    #[test]
964    fn test_scb_mean_valid_output() {
965        let data = make_test_data();
966        let m = data.ncols();
967        let t = uniform_grid(m);
968
969        let band = scb_mean_degras(&data, &t, 0.2, 100, 0.95, MultiplierDistribution::Gaussian);
970        let band = band.expect("SCB mean should succeed");
971
972        assert_eq!(band.lower.len(), m);
973        assert_eq!(band.upper.len(), m);
974        for j in 0..m {
975            assert!(band.lower[j] < band.upper[j]);
976        }
977    }
978
979    #[test]
980    fn test_scb_gaussian_vs_rademacher() {
981        let data = make_test_data();
982        let m = data.ncols();
983        let t = uniform_grid(m);
984
985        let gauss =
986            scb_mean_degras(&data, &t, 0.2, 200, 0.95, MultiplierDistribution::Gaussian).unwrap();
987        let radem = scb_mean_degras(
988            &data,
989            &t,
990            0.2,
991            200,
992            0.95,
993            MultiplierDistribution::Rademacher,
994        )
995        .unwrap();
996
997        // Both should produce valid bands; widths may differ but both should be positive
998        let gauss_mean_hw: f64 = gauss.half_width.iter().sum::<f64>() / m as f64;
999        let radem_mean_hw: f64 = radem.half_width.iter().sum::<f64>() / m as f64;
1000        assert!(gauss_mean_hw > 0.0);
1001        assert!(radem_mean_hw > 0.0);
1002    }
1003
1004    #[test]
1005    fn test_scb_narrows_with_more_data() {
1006        let m = 50;
1007        let t = uniform_grid(m);
1008
1009        let data_small = sim_fundata(
1010            20,
1011            &t,
1012            5,
1013            EFunType::Fourier,
1014            EValType::Exponential,
1015            Some(42),
1016        );
1017        let data_large = sim_fundata(
1018            200,
1019            &t,
1020            5,
1021            EFunType::Fourier,
1022            EValType::Exponential,
1023            Some(42),
1024        );
1025
1026        let band_small = scb_mean_degras(
1027            &data_small,
1028            &t,
1029            0.2,
1030            100,
1031            0.95,
1032            MultiplierDistribution::Gaussian,
1033        )
1034        .unwrap();
1035        let band_large = scb_mean_degras(
1036            &data_large,
1037            &t,
1038            0.2,
1039            100,
1040            0.95,
1041            MultiplierDistribution::Gaussian,
1042        )
1043        .unwrap();
1044
1045        let hw_small: f64 = band_small.half_width.iter().sum::<f64>() / m as f64;
1046        let hw_large: f64 = band_large.half_width.iter().sum::<f64>() / m as f64;
1047
1048        assert!(
1049            hw_large < hw_small,
1050            "SCB should narrow with more data: hw_small={hw_small}, hw_large={hw_large}"
1051        );
1052    }
1053
1054    #[test]
1055    fn test_scb_invalid_input() {
1056        let data = make_test_data();
1057        let t = uniform_grid(data.ncols());
1058
1059        assert!(
1060            scb_mean_degras(&data, &t, 0.0, 100, 0.95, MultiplierDistribution::Gaussian).is_none()
1061        );
1062        assert!(
1063            scb_mean_degras(&data, &t, 0.2, 0, 0.95, MultiplierDistribution::Gaussian).is_none()
1064        );
1065        assert!(
1066            scb_mean_degras(&data, &t, 0.2, 100, 0.0, MultiplierDistribution::Gaussian).is_none()
1067        );
1068        // Wrong argvals length
1069        let wrong_t = uniform_grid(data.ncols() + 1);
1070        assert!(scb_mean_degras(
1071            &data,
1072            &wrong_t,
1073            0.2,
1074            100,
1075            0.95,
1076            MultiplierDistribution::Gaussian
1077        )
1078        .is_none());
1079    }
1080
1081    // ── Exponential family tests ──
1082
1083    #[test]
1084    fn test_exp_family_gaussian_matches_fpca() {
1085        let data = make_test_data();
1086
1087        let exp_band =
1088            exponential_family_tolerance_band(&data, ExponentialFamily::Gaussian, 3, 100, 0.95, 42)
1089                .unwrap();
1090
1091        let fpca_band =
1092            fpca_tolerance_band(&data, 3, 100, 0.95, BandType::Simultaneous, 42).unwrap();
1093
1094        // Gaussian family with identity link should produce the same band
1095        for j in 0..data.ncols() {
1096            assert!(
1097                (exp_band.lower[j] - fpca_band.lower[j]).abs() < 1e-10,
1098                "Gaussian family should match FPCA at point {j}"
1099            );
1100            assert!(
1101                (exp_band.upper[j] - fpca_band.upper[j]).abs() < 1e-10,
1102                "Gaussian family should match FPCA at point {j}"
1103            );
1104        }
1105    }
1106
1107    #[test]
1108    fn test_exp_family_poisson() {
1109        // Create data that looks like Poisson counts (positive)
1110        let m = 30;
1111        let t = uniform_grid(m);
1112        let raw = sim_fundata(
1113            40,
1114            &t,
1115            3,
1116            EFunType::Fourier,
1117            EValType::Exponential,
1118            Some(99),
1119        );
1120
1121        // Shift to positive range and add offset
1122        let mut data = FdMatrix::zeros(40, m);
1123        for j in 0..m {
1124            for i in 0..40 {
1125                data[(i, j)] = (raw[(i, j)] + 5.0).max(0.1); // ensure positive
1126            }
1127        }
1128
1129        let band =
1130            exponential_family_tolerance_band(&data, ExponentialFamily::Poisson, 3, 50, 0.95, 42);
1131        let band = band.expect("Poisson band should succeed");
1132
1133        // All bounds should be positive (exp of anything is positive)
1134        for j in 0..m {
1135            assert!(
1136                band.lower[j] > 0.0,
1137                "Poisson lower bound should be positive"
1138            );
1139            assert!(
1140                band.upper[j] > 0.0,
1141                "Poisson upper bound should be positive"
1142            );
1143        }
1144    }
1145
1146    #[test]
1147    fn test_exp_family_binomial() {
1148        // Create data in (0, 1) range
1149        let m = 30;
1150        let t = uniform_grid(m);
1151        let raw = sim_fundata(
1152            40,
1153            &t,
1154            3,
1155            EFunType::Fourier,
1156            EValType::Exponential,
1157            Some(77),
1158        );
1159
1160        let mut data = FdMatrix::zeros(40, m);
1161        for j in 0..m {
1162            for i in 0..40 {
1163                // Map to (0, 1) via sigmoid
1164                data[(i, j)] = 1.0 / (1.0 + (-raw[(i, j)]).exp());
1165            }
1166        }
1167
1168        let band =
1169            exponential_family_tolerance_band(&data, ExponentialFamily::Binomial, 3, 50, 0.95, 42);
1170        let band = band.expect("Binomial band should succeed");
1171
1172        // All bounds should be in (0, 1) (inverse logit maps to (0, 1))
1173        for j in 0..m {
1174            assert!(
1175                band.lower[j] > 0.0 && band.lower[j] < 1.0,
1176                "Binomial lower bound at {j} = {} should be in (0,1)",
1177                band.lower[j]
1178            );
1179            assert!(
1180                band.upper[j] > 0.0 && band.upper[j] < 1.0,
1181                "Binomial upper bound at {j} = {} should be in (0,1)",
1182                band.upper[j]
1183            );
1184        }
1185    }
1186
1187    #[test]
1188    fn test_exp_family_invalid_input() {
1189        let data = make_test_data();
1190        assert!(exponential_family_tolerance_band(
1191            &data,
1192            ExponentialFamily::Gaussian,
1193            0,
1194            100,
1195            0.95,
1196            42
1197        )
1198        .is_none());
1199        assert!(exponential_family_tolerance_band(
1200            &data,
1201            ExponentialFamily::Gaussian,
1202            3,
1203            0,
1204            0.95,
1205            42
1206        )
1207        .is_none());
1208    }
1209
1210    // ── Elastic tolerance band tests ──
1211
1212    fn make_elastic_test_data() -> (FdMatrix, Vec<f64>) {
1213        let m = 50;
1214        let t = uniform_grid(m);
1215        let data = sim_fundata(
1216            30,
1217            &t,
1218            5,
1219            EFunType::Fourier,
1220            EValType::Exponential,
1221            Some(42),
1222        );
1223        (data, t)
1224    }
1225
1226    #[test]
1227    fn test_elastic_band_valid_output() {
1228        let (data, t) = make_elastic_test_data();
1229        let m = t.len();
1230
1231        let band = elastic_tolerance_band(&data, &t, 3, 50, 0.95, BandType::Pointwise, 5, 42);
1232        let band = band.expect("Elastic band should succeed");
1233
1234        assert_eq!(band.lower.len(), m);
1235        assert_eq!(band.upper.len(), m);
1236        assert_eq!(band.center.len(), m);
1237        assert_eq!(band.half_width.len(), m);
1238    }
1239
1240    #[test]
1241    fn test_elastic_band_lower_less_than_upper() {
1242        let (data, t) = make_elastic_test_data();
1243        let m = t.len();
1244
1245        let band =
1246            elastic_tolerance_band(&data, &t, 3, 50, 0.95, BandType::Pointwise, 5, 42).unwrap();
1247
1248        for j in 0..m {
1249            assert!(
1250                band.lower[j] < band.upper[j],
1251                "lower[{j}] = {} >= upper[{j}] = {}",
1252                band.lower[j],
1253                band.upper[j]
1254            );
1255        }
1256    }
1257
1258    #[test]
1259    fn test_elastic_band_center_within_bounds() {
1260        let (data, t) = make_elastic_test_data();
1261        let m = t.len();
1262
1263        let band =
1264            elastic_tolerance_band(&data, &t, 3, 50, 0.95, BandType::Pointwise, 5, 42).unwrap();
1265
1266        for j in 0..m {
1267            assert!(
1268                band.center[j] >= band.lower[j] && band.center[j] <= band.upper[j],
1269                "center[{j}]={} should be in [{}, {}]",
1270                band.center[j],
1271                band.lower[j],
1272                band.upper[j]
1273            );
1274        }
1275    }
1276
1277    #[test]
1278    fn test_elastic_band_half_width_positive() {
1279        let (data, t) = make_elastic_test_data();
1280
1281        let band =
1282            elastic_tolerance_band(&data, &t, 3, 50, 0.95, BandType::Pointwise, 5, 42).unwrap();
1283
1284        for (j, &hw) in band.half_width.iter().enumerate() {
1285            assert!(hw > 0.0, "half_width[{j}] should be positive, got {hw}");
1286        }
1287    }
1288
1289    #[test]
1290    fn test_elastic_band_simultaneous() {
1291        let (data, t) = make_elastic_test_data();
1292        let m = t.len();
1293
1294        let band = elastic_tolerance_band(&data, &t, 3, 50, 0.95, BandType::Simultaneous, 5, 42);
1295        let band = band.expect("Elastic simultaneous band should succeed");
1296
1297        assert_eq!(band.lower.len(), m);
1298        for j in 0..m {
1299            assert!(band.lower[j] < band.upper[j]);
1300        }
1301    }
1302
1303    #[test]
1304    fn test_elastic_band_deterministic() {
1305        let (data, t) = make_elastic_test_data();
1306
1307        let b1 =
1308            elastic_tolerance_band(&data, &t, 3, 50, 0.95, BandType::Pointwise, 5, 123).unwrap();
1309        let b2 =
1310            elastic_tolerance_band(&data, &t, 3, 50, 0.95, BandType::Pointwise, 5, 123).unwrap();
1311
1312        for j in 0..t.len() {
1313            assert_eq!(
1314                b1.lower[j], b2.lower[j],
1315                "lower[{j}] should be deterministic"
1316            );
1317            assert_eq!(
1318                b1.upper[j], b2.upper[j],
1319                "upper[{j}] should be deterministic"
1320            );
1321        }
1322    }
1323
1324    #[test]
1325    fn test_elastic_band_higher_coverage_wider() {
1326        let (data, t) = make_elastic_test_data();
1327
1328        let b90 =
1329            elastic_tolerance_band(&data, &t, 3, 100, 0.90, BandType::Pointwise, 5, 42).unwrap();
1330        let b99 =
1331            elastic_tolerance_band(&data, &t, 3, 100, 0.99, BandType::Pointwise, 5, 42).unwrap();
1332
1333        let hw90: f64 = b90.half_width.iter().sum();
1334        let hw99: f64 = b99.half_width.iter().sum();
1335
1336        assert!(
1337            hw99 > hw90,
1338            "99% coverage band should be wider than 90%: hw99={hw99:.4}, hw90={hw90:.4}"
1339        );
1340    }
1341
1342    #[test]
1343    fn test_elastic_band_invalid_input() {
1344        let (data, t) = make_elastic_test_data();
1345
1346        // Wrong argvals length
1347        let wrong_t = uniform_grid(t.len() + 1);
1348        assert!(
1349            elastic_tolerance_band(&data, &wrong_t, 3, 50, 0.95, BandType::Pointwise, 5, 42)
1350                .is_none()
1351        );
1352
1353        // max_iter = 0
1354        assert!(
1355            elastic_tolerance_band(&data, &t, 3, 50, 0.95, BandType::Pointwise, 0, 42).is_none()
1356        );
1357
1358        // ncomp = 0
1359        assert!(
1360            elastic_tolerance_band(&data, &t, 0, 50, 0.95, BandType::Pointwise, 5, 42).is_none()
1361        );
1362
1363        // nb = 0
1364        assert!(
1365            elastic_tolerance_band(&data, &t, 3, 0, 0.95, BandType::Pointwise, 5, 42).is_none()
1366        );
1367
1368        // coverage out of range
1369        assert!(
1370            elastic_tolerance_band(&data, &t, 3, 50, 0.0, BandType::Pointwise, 5, 42).is_none()
1371        );
1372        assert!(
1373            elastic_tolerance_band(&data, &t, 3, 50, 1.0, BandType::Pointwise, 5, 42).is_none()
1374        );
1375
1376        // Too few observations
1377        let tiny = FdMatrix::zeros(2, t.len());
1378        assert!(
1379            elastic_tolerance_band(&tiny, &t, 1, 10, 0.95, BandType::Pointwise, 5, 42).is_none()
1380        );
1381    }
1382}