Skip to main content

fdars_core/
outliers.rs

1//! Outlier detection for functional data.
2//!
3//! This module provides methods for detecting outliers in functional data
4//! based on depth measures and likelihood ratio tests.
5
6use crate::depth::band::{modified_band_1d, modified_epigraph_index_1d};
7use crate::depth::{functional_boxplot, total_variation_depth_1d, DepthMethod};
8use crate::error::FdarError;
9use crate::helpers::{quantile_sorted, sort_nan_safe};
10use crate::iter_maybe_parallel;
11use crate::matrix::FdMatrix;
12use crate::streaming_depth::{SortedReferenceState, StreamingDepth, StreamingFraimanMuniz};
13use rand::prelude::*;
14use rand_distr::StandardNormal;
15#[cfg(feature = "parallel")]
16use rayon::iter::ParallelIterator;
17
18/// Compute trimmed mean and variance from data using depth-based trimming.
19///
20/// Returns (trimmed_mean, trimmed_var) each of length m.
21fn compute_trimmed_stats(data: &FdMatrix, depths: &[f64], n_keep: usize) -> (Vec<f64>, Vec<f64>) {
22    let m = data.ncols();
23
24    let mut depth_idx: Vec<(usize, f64)> =
25        depths.iter().enumerate().map(|(i, &d)| (i, d)).collect();
26    // O(n) partial sort instead of O(n log n) full sort — we only need the top n_keep elements
27    if n_keep < depth_idx.len() {
28        depth_idx.select_nth_unstable_by(n_keep - 1, |a, b| {
29            b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
30        });
31    }
32    let keep_idx: Vec<usize> = depth_idx[..n_keep].iter().map(|(i, _)| *i).collect();
33
34    let results: Vec<(f64, f64)> = iter_maybe_parallel!(0..m)
35        .map(|j| {
36            let mut mean_j = 0.0;
37            for &i in &keep_idx {
38                mean_j += data[(i, j)];
39            }
40            mean_j /= n_keep as f64;
41
42            let mut var_j = 0.0;
43            for &i in &keep_idx {
44                let diff = data[(i, j)] - mean_j;
45                var_j += diff * diff;
46            }
47            var_j /= n_keep as f64;
48            var_j = var_j.max(1e-10);
49
50            (mean_j, var_j)
51        })
52        .collect();
53
54    let trimmed_mean: Vec<f64> = results.iter().map(|&(m, _)| m).collect();
55    let trimmed_var: Vec<f64> = results.iter().map(|&(_, v)| v).collect();
56
57    (trimmed_mean, trimmed_var)
58}
59
60/// Compute normalized Mahalanobis-like distance for a single observation.
61fn normalized_distance(
62    data: &FdMatrix,
63    i: usize,
64    trimmed_mean: &[f64],
65    trimmed_var: &[f64],
66) -> f64 {
67    let m = data.ncols();
68    let mut dist = 0.0;
69    for j in 0..m {
70        let diff = data[(i, j)] - trimmed_mean[j];
71        dist += diff * diff / trimmed_var[j];
72    }
73    (dist / m as f64).sqrt()
74}
75
76/// Compute bootstrap threshold for LRT outlier detection.
77///
78/// # Arguments
79/// * `data` - Functional data matrix (n observations x m evaluation points)
80/// * `nb` - Number of bootstrap iterations
81/// * `smo` - Smoothing parameter for bootstrap
82/// * `trim` - Trimming proportion
83/// * `seed` - Random seed
84/// * `percentile` - Percentile for threshold (e.g., 0.99 for 99th percentile)
85///
86/// # Returns
87/// Threshold at specified percentile for outlier detection
88#[must_use = "expensive computation whose result should not be discarded"]
89pub fn outliers_threshold_lrt(
90    data: &FdMatrix,
91    nb: usize,
92    smo: f64,
93    trim: f64,
94    seed: u64,
95    percentile: f64,
96) -> f64 {
97    outliers_threshold_lrt_with_dist(data, nb, smo, trim, seed, percentile).0
98}
99
100/// Compute bootstrap threshold and full null distribution for LRT outlier detection.
101///
102/// Same as [`outliers_threshold_lrt`] but also returns the sorted bootstrap
103/// distribution of max-distances, enabling per-curve p-value computation:
104/// `p = (sum(boot_dist >= d) + 1) / (B + 1)`.
105///
106/// # Arguments
107/// * `data` - Functional data matrix (n observations x m evaluation points)
108/// * `nb` - Number of bootstrap iterations
109/// * `smo` - Smoothing parameter for bootstrap
110/// * `trim` - Trimming proportion
111/// * `seed` - Random seed
112/// * `percentile` - Percentile for threshold (e.g., 0.99 for 99th percentile)
113///
114/// # Returns
115/// `(threshold, sorted_distribution)` — threshold at specified percentile and the
116/// full sorted bootstrap null distribution of max-distances (length `nb`).
117#[must_use = "expensive computation whose result should not be discarded"]
118pub fn outliers_threshold_lrt_with_dist(
119    data: &FdMatrix,
120    nb: usize,
121    smo: f64,
122    trim: f64,
123    seed: u64,
124    percentile: f64,
125) -> (f64, Vec<f64>) {
126    let n = data.nrows();
127    let m = data.ncols();
128
129    if n < 3 || m == 0 {
130        return (0.0, vec![]);
131    }
132
133    let n_keep = ((1.0 - trim) * n as f64).ceil().max(1.0) as usize;
134    let n_keep = n_keep.min(n);
135
136    // Compute column standard deviations for smoothing
137    let col_vars: Vec<f64> = iter_maybe_parallel!(0..m)
138        .map(|j| {
139            let mut sum = 0.0;
140            let mut sum_sq = 0.0;
141            for i in 0..n {
142                let val = data[(i, j)];
143                sum += val;
144                sum_sq += val * val;
145            }
146            let mean = sum / n as f64;
147            // Bootstrap variance, population formula
148            let var = sum_sq / n as f64 - mean * mean;
149            var.max(0.0).sqrt()
150        })
151        .collect();
152
153    // Run bootstrap iterations in parallel
154    let max_dists: Vec<f64> = iter_maybe_parallel!(0..nb)
155        .map(|b| {
156            let mut rng = StdRng::seed_from_u64(seed.wrapping_add(b as u64));
157
158            // Resample with replacement and add smoothing noise
159            let indices: Vec<usize> = (0..n).map(|_| rng.gen_range(0..n)).collect();
160            // Pre-generate all noise values to preserve RNG sequence
161            let noise_vals: Vec<f64> = (0..n * m)
162                .map(|_| rng.sample::<f64, _>(StandardNormal))
163                .collect();
164            let mut boot_data = FdMatrix::zeros(n, m);
165            // Column-first iteration for cache-friendly writes in column-major layout
166            for j in 0..m {
167                let smo_var = smo * col_vars[j];
168                for (new_i, &old_i) in indices.iter().enumerate() {
169                    let noise = noise_vals[new_i * m + j] * smo_var;
170                    boot_data[(new_i, j)] = data[(old_i, j)] + noise;
171                }
172            }
173
174            // Compute trimmed stats from bootstrap sample
175            let state = SortedReferenceState::from_reference(&boot_data);
176            let streaming_fm = StreamingFraimanMuniz::new(state, true);
177            let depths = streaming_fm.depth_batch(&boot_data);
178            let (trimmed_mean, trimmed_var) = compute_trimmed_stats(&boot_data, &depths, n_keep);
179
180            // Find max normalized distance across all observations
181            (0..n)
182                .map(|i| normalized_distance(&boot_data, i, &trimmed_mean, &trimmed_var))
183                .fold(0.0_f64, f64::max)
184        })
185        .collect();
186
187    // Sort and extract threshold at specified percentile
188    let mut sorted_dists = max_dists;
189    crate::helpers::sort_nan_safe(&mut sorted_dists);
190    let idx =
191        crate::utility::f64_to_usize_clamped(nb as f64 * percentile).min(nb.saturating_sub(1));
192    let threshold = sorted_dists.get(idx).copied().unwrap_or(0.0);
193    (threshold, sorted_dists)
194}
195
196/// Detect outliers using LRT method.
197///
198/// # Arguments
199/// * `data` - Functional data matrix (n observations x m evaluation points)
200/// * `threshold` - Outlier threshold
201/// * `trim` - Trimming proportion
202///
203/// # Returns
204/// Vector of booleans indicating outliers
205#[must_use = "expensive computation whose result should not be discarded"]
206/// Detect outliers in functional data using the Likelihood Ratio Test.
207///
208/// Compares each observation's normalized distance against a threshold.
209/// Observations exceeding the threshold are flagged as outliers.
210///
211/// # Arguments
212/// * `data` - Functional data matrix (n x m)
213/// * `threshold` - Decision threshold (from [`outliers_threshold_lrt`])
214/// * `trim` - Trimming proportion for robust estimation
215///
216/// # Examples
217///
218/// ```
219/// use fdars_core::matrix::FdMatrix;
220/// use fdars_core::outliers::detect_outliers_lrt;
221///
222/// let data = FdMatrix::from_column_major(
223///     (0..50).map(|i| (i as f64 * 0.1).sin()).collect(),
224///     5, 10,
225/// ).unwrap();
226/// let outliers = detect_outliers_lrt(&data, 3.0, 0.1);
227/// assert_eq!(outliers.len(), 5);
228/// ```
229pub fn detect_outliers_lrt(data: &FdMatrix, threshold: f64, trim: f64) -> Vec<bool> {
230    let n = data.nrows();
231    let m = data.ncols();
232
233    if n < 3 || m == 0 {
234        return vec![false; n];
235    }
236
237    let n_keep = ((1.0 - trim) * n as f64).ceil().max(1.0) as usize;
238    let n_keep = n_keep.min(n);
239
240    let state = SortedReferenceState::from_reference(data);
241    let streaming_fm = StreamingFraimanMuniz::new(state, true);
242    let depths = streaming_fm.depth_batch(data);
243    let (trimmed_mean, trimmed_var) = compute_trimmed_stats(data, &depths, n_keep);
244
245    iter_maybe_parallel!(0..n)
246        .map(|i| normalized_distance(data, i, &trimmed_mean, &trimmed_var) > threshold)
247        .collect()
248}
249
250/// Result of the outliergram analysis.
251#[derive(Debug, Clone, PartialEq)]
252#[non_exhaustive]
253pub struct OutligramResult {
254    /// Modified Epigraph Index for each curve
255    pub mei: Vec<f64>,
256    /// Modified Band Depth for each curve
257    pub mbd: Vec<f64>,
258    /// Parabola coefficients: MBD = a0 + a1*MEI + a2*MEI²
259    pub a0: f64,
260    pub a1: f64,
261    pub a2: f64,
262    /// Outlier threshold (IQR-based)
263    pub threshold: f64,
264    /// Outlier flags (true = outlier)
265    pub outlier_flags: Vec<bool>,
266}
267
268/// Compute outliergram with parabolic outlier boundary.
269///
270/// Combines modified band depth and modified epigraph index to detect
271/// shape outliers. Fits a parabolic upper bound `MBD = a0 + a1*MEI + a2*MEI²`
272/// and flags curves whose residuals fall below an IQR-based threshold.
273///
274/// # Arguments
275/// * `data` - Functional data matrix (n x m)
276/// * `factor` - IQR multiplier for the outlier threshold (typically 1.5)
277///
278/// # Returns
279/// Outliergram result with depths, parabola coefficients, and outlier flags.
280pub fn outliergram(data: &FdMatrix, factor: f64) -> Result<OutligramResult, FdarError> {
281    let n = data.nrows();
282    if n < 3 {
283        return Err(FdarError::InvalidDimension {
284            parameter: "data",
285            expected: "at least 3 rows".to_string(),
286            actual: format!("{n} rows"),
287        });
288    }
289
290    let mei = modified_epigraph_index_1d(data, data);
291    let mbd = modified_band_1d(data, data);
292
293    // Fit parabola: MBD = a0 + a1*MEI + a2*MEI²
294    // Using normal equations: X = [1, mei, mei²], y = mbd
295    let mut xtx = [[0.0; 3]; 3];
296    let mut xty = [0.0; 3];
297    for i in 0..n {
298        let x = [1.0, mei[i], mei[i] * mei[i]];
299        for r in 0..3 {
300            for c in 0..3 {
301                xtx[r][c] += x[r] * x[c];
302            }
303            xty[r] += x[r] * mbd[i];
304        }
305    }
306
307    // Solve 3x3 system via Cramer's rule
308    let (a0, a1, a2) = solve_3x3(xtx, xty);
309
310    // Compute residuals below the parabola
311    let residuals: Vec<f64> = (0..n)
312        .map(|i| mbd[i] - (a0 + a1 * mei[i] + a2 * mei[i] * mei[i]))
313        .collect();
314
315    // IQR-based threshold on residuals
316    let mut sorted_resid = residuals.clone();
317    sorted_resid.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
318    let q1 = sorted_resid[n / 4];
319    let q3 = sorted_resid[3 * n / 4];
320    let iqr = q3 - q1;
321    let threshold = q1 - factor * iqr;
322
323    let outlier_flags: Vec<bool> = residuals.iter().map(|&r| r < threshold).collect();
324
325    Ok(OutligramResult {
326        mei,
327        mbd,
328        a0,
329        a1,
330        a2,
331        threshold,
332        outlier_flags,
333    })
334}
335
336/// Result of magnitude-shape outlyingness decomposition.
337#[derive(Debug, Clone, PartialEq)]
338#[non_exhaustive]
339pub struct MagnitudeShapeResult {
340    /// Magnitude outlyingness: 1 - MBD (how far from the center)
341    pub magnitude: Vec<f64>,
342    /// Shape outlyingness: L2 distance of normalized curve direction from mean direction
343    pub shape: Vec<f64>,
344}
345
346/// Decompose outlyingness into magnitude and shape components.
347///
348/// Magnitude measures how far a curve is from the center (1 - modified band depth).
349/// Shape measures how different the curve's direction is from the mean direction
350/// (L2 distance of normalized centered curves).
351///
352/// # Arguments
353/// * `data` - Functional data matrix (n x m)
354pub fn magnitude_shape_outlyingness(data: &FdMatrix) -> Result<MagnitudeShapeResult, FdarError> {
355    let (n, m) = data.shape();
356    if n < 2 || m == 0 {
357        return Err(FdarError::InvalidDimension {
358            parameter: "data",
359            expected: "at least 2 rows and 1 column".to_string(),
360            actual: format!("{n} rows, {m} columns"),
361        });
362    }
363
364    // Magnitude: 1 - modified band depth
365    let mbd = modified_band_1d(data, data);
366    let magnitude: Vec<f64> = mbd.iter().map(|&d| 1.0 - d).collect();
367
368    // Shape: compute centered curves, normalize directions, compare to mean direction
369    // Step 1: compute column means
370    let mut col_means = vec![0.0; m];
371    for j in 0..m {
372        for i in 0..n {
373            col_means[j] += data[(i, j)];
374        }
375        col_means[j] /= n as f64;
376    }
377
378    // Step 2: center and normalize each curve (direction)
379    let mut directions = vec![vec![0.0; m]; n];
380    for i in 0..n {
381        let mut norm_sq = 0.0;
382        for j in 0..m {
383            let c = data[(i, j)] - col_means[j];
384            directions[i][j] = c;
385            norm_sq += c * c;
386        }
387        let norm = norm_sq.sqrt().max(1e-15);
388        for j in 0..m {
389            directions[i][j] /= norm;
390        }
391    }
392
393    // Step 3: mean direction
394    let mut mean_dir = vec![0.0; m];
395    for i in 0..n {
396        for j in 0..m {
397            mean_dir[j] += directions[i][j];
398        }
399    }
400    let mut mean_norm_sq = 0.0;
401    for j in 0..m {
402        mean_dir[j] /= n as f64;
403        mean_norm_sq += mean_dir[j] * mean_dir[j];
404    }
405    let mean_norm = mean_norm_sq.sqrt().max(1e-15);
406    for j in 0..m {
407        mean_dir[j] /= mean_norm;
408    }
409
410    // Step 4: shape = L2 distance from mean direction
411    let shape: Vec<f64> = (0..n)
412        .map(|i| {
413            let dist_sq: f64 = (0..m)
414                .map(|j| {
415                    let d = directions[i][j] - mean_dir[j];
416                    d * d
417                })
418                .sum();
419            dist_sq.sqrt()
420        })
421        .collect();
422
423    Ok(MagnitudeShapeResult { magnitude, shape })
424}
425
426/// Solve a 3x3 linear system via Cramer's rule.
427fn solve_3x3(a: [[f64; 3]; 3], b: [f64; 3]) -> (f64, f64, f64) {
428    let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
429        - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
430        + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
431    if det.abs() < 1e-15 {
432        return (0.0, 0.0, 0.0);
433    }
434
435    let det_x = b[0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
436        - a[0][1] * (b[1] * a[2][2] - a[1][2] * b[2])
437        + a[0][2] * (b[1] * a[2][1] - a[1][1] * b[2]);
438
439    let det_y = a[0][0] * (b[1] * a[2][2] - a[1][2] * b[2])
440        - b[0] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
441        + a[0][2] * (a[1][0] * b[2] - b[1] * a[2][0]);
442
443    let det_z = a[0][0] * (a[1][1] * b[2] - b[1] * a[2][1])
444        - a[0][1] * (a[1][0] * b[2] - b[1] * a[2][0])
445        + b[0] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
446
447    (det_x / det, det_y / det, det_z / det)
448}
449
450/// Lower and upper IQR fences `(Q1 − factor·IQR, Q3 + factor·IQR)` for a value slice.
451///
452/// Shared cutoff helper for the outlier detectors. Uses linear-interpolation quantiles
453/// ([`quantile_sorted`]) rather than R's floor-index quartiles — the two agree for n > 20 and
454/// differ only marginally for small samples.
455fn iqr_fence(values: &[f64], factor: f64) -> (f64, f64) {
456    let mut sorted = values.to_vec();
457    sort_nan_safe(&mut sorted);
458    let q1 = quantile_sorted(&sorted, 0.25);
459    let q3 = quantile_sorted(&sorted, 0.75);
460    let iqr = q3 - q1;
461    (q1 - factor * iqr, q3 + factor * iqr)
462}
463
464/// Configuration for [`tvdmss`]. Defaults reproduce the `fdaoutlier` `tvdmss` defaults.
465#[derive(Debug, Clone, Copy, PartialEq)]
466#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
467pub struct TvdMssConfig {
468    /// IQR multiplier for the shape (MSS lower-fence) stage. Default `1.5`.
469    pub emp_factor_mss: f64,
470    /// Inflation factor for the magnitude-stage functional boxplot. Default `1.5`.
471    pub emp_factor_tvd: f64,
472    /// Fraction of the original n used as the stage-2 central region. Default `0.5`.
473    /// Documented for parity with `fdaoutlier`; fdars' [`functional_boxplot`] fixes the
474    /// central region at the deepest 50%, so this field is currently informational.
475    pub central_region_tvd: f64,
476}
477
478impl Default for TvdMssConfig {
479    fn default() -> Self {
480        Self {
481            emp_factor_mss: 1.5,
482            emp_factor_tvd: 1.5,
483            central_region_tvd: 0.5,
484        }
485    }
486}
487
488/// Numeric output of the [`tvdmss`] two-stage detector (no rendering).
489#[derive(Debug, Clone, PartialEq)]
490#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
491#[non_exhaustive]
492pub struct TvdMssOutliers {
493    /// Row indices flagged as magnitude outliers (stage 2: functional boxplot on TVD).
494    pub magnitude_outliers: Vec<usize>,
495    /// Row indices flagged as shape outliers (stage 1: lower IQR fence on MSS).
496    pub shape_outliers: Vec<usize>,
497    /// Total variation depth per curve (over original indices).
498    pub tvd: Vec<f64>,
499    /// Modified shape similarity index per curve (over original indices).
500    pub mss: Vec<f64>,
501}
502
503/// TVD + MSSI two-stage outlier detector (Huang & Sun 2019 / `fdaoutlier::tvdmss`).
504///
505/// Stage 1 flags **shape outliers** as curves whose MSS falls below the lower IQR fence
506/// (`Q1 − emp_factor_mss·IQR`) and below the mean MSS. Stage 2 removes those curves and runs a
507/// López-Pintado–Romo depth-fence [`functional_boxplot`] on the remaining curves' values to flag
508/// **magnitude outliers**, re-mapping the reduced indices back to the original sample.
509///
510/// Consumes Phase 28's [`total_variation_depth_1d`] directly for the `tvd`/`mss` vectors — no
511/// reimplementation. **Divergence:** `fdaoutlier` scales the stage-2 central region by
512/// `n_orig / n_reduced`; fdars' `functional_boxplot` fixes the central region at the deepest 50%
513/// of the reduced set, so `central_region_tvd` is informational only.
514///
515/// # Errors
516/// Returns [`FdarError::InvalidDimension`] if the sample has fewer than 3 curves or zero columns.
517#[must_use = "outlier detection results should not be discarded"]
518pub fn tvdmss(data: &FdMatrix, config: TvdMssConfig) -> Result<TvdMssOutliers, FdarError> {
519    let (n, m) = data.shape();
520    if n < 3 || m == 0 {
521        return Err(FdarError::InvalidDimension {
522            parameter: "data",
523            expected: "at least 3 curves and 1 column".to_string(),
524            actual: format!("{n} rows, {m} columns"),
525        });
526    }
527
528    let depth = total_variation_depth_1d(data, data)?;
529
530    // Stage 1: shape outliers = low-MSS lower tail.
531    let (lower_mss, _) = iqr_fence(&depth.mss, config.emp_factor_mss);
532    let mean_mss = depth.mss.iter().sum::<f64>() / n as f64;
533    let shape_outliers: Vec<usize> = (0..n)
534        .filter(|&i| depth.mss[i] < lower_mss && depth.mss[i] < mean_mss)
535        .collect();
536
537    // Stage 2: magnitude outliers via functional boxplot on the non-shape curves.
538    let keep: Vec<usize> = (0..n).filter(|i| !shape_outliers.contains(i)).collect();
539    let mut magnitude_outliers = Vec::new();
540    if keep.len() >= 3 {
541        let kn = keep.len();
542        let mut col_major = vec![0.0; kn * m];
543        for (r, &orig) in keep.iter().enumerate() {
544            for j in 0..m {
545                col_major[r + j * kn] = data[(orig, j)];
546            }
547        }
548        let reduced = FdMatrix::from_column_major(col_major, kn, m)?;
549        let fbp = functional_boxplot(&reduced, DepthMethod::ModifiedBand, config.emp_factor_tvd)?;
550        magnitude_outliers = fbp.outliers.iter().map(|&r| keep[r]).collect();
551    }
552
553    Ok(TvdMssOutliers {
554        magnitude_outliers,
555        shape_outliers,
556        tvd: depth.tvd,
557        mss: depth.mss,
558    })
559}
560
561/// Configuration for [`muod`].
562#[derive(Debug, Clone, Copy, PartialEq)]
563#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
564pub struct MuodConfig {
565    /// IQR multiplier for the per-index upper boxplot cutoff. Default `1.5`.
566    pub factor: f64,
567}
568
569impl Default for MuodConfig {
570    fn default() -> Self {
571        Self { factor: 1.5 }
572    }
573}
574
575/// Numeric output of the [`muod`] detector: per-curve indices and flagged sets (no rendering).
576#[derive(Debug, Clone, PartialEq)]
577#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
578#[non_exhaustive]
579pub struct MuodResult {
580    /// Row indices flagged as shape outliers (upper fence on the shape index).
581    pub shape_outliers: Vec<usize>,
582    /// Row indices flagged as magnitude outliers (upper fence on the magnitude index).
583    pub magnitude_outliers: Vec<usize>,
584    /// Row indices flagged as amplitude outliers (upper fence on the amplitude index).
585    pub amplitude_outliers: Vec<usize>,
586    /// Shape index per curve: `|corr(X_i, μ) − 1|` (0 = same shape as the mean).
587    pub shape_index: Vec<f64>,
588    /// Magnitude index per curve: `|intercept_i|` (0 = same location as the mean).
589    pub magnitude_index: Vec<f64>,
590    /// Amplitude index per curve: `|slope_i − 1|` (0 = same amplitude as the mean).
591    pub amplitude_index: Vec<f64>,
592}
593
594/// Per-curve MUOD indices `(shape, magnitude, amplitude)` from OLS regression on the pointwise mean.
595fn muod_indices(data: &FdMatrix) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
596    let (n, m) = data.shape();
597
598    // Pointwise mean μ_t and its moments.
599    let mut mu = vec![0.0; m];
600    for (j, mu_j) in mu.iter_mut().enumerate() {
601        let mut s = 0.0;
602        for i in 0..n {
603            s += data[(i, j)];
604        }
605        *mu_j = s / n as f64;
606    }
607    let mu_mean = mu.iter().sum::<f64>() / m as f64;
608    let mu_var = mu.iter().map(|&v| (v - mu_mean).powi(2)).sum::<f64>() / (m as f64 - 1.0);
609    let mu_std = mu_var.sqrt();
610
611    let triples: Vec<(f64, f64, f64)> = iter_maybe_parallel!(0..n)
612        .map(|i| {
613            let mut xi_mean = 0.0;
614            for j in 0..m {
615                xi_mean += data[(i, j)];
616            }
617            xi_mean /= m as f64;
618
619            let mut cov = 0.0;
620            let mut xi_var = 0.0;
621            for j in 0..m {
622                let dx = data[(i, j)] - xi_mean;
623                let dmu = mu[j] - mu_mean;
624                cov += dx * dmu;
625                xi_var += dx * dx;
626            }
627            cov /= m as f64 - 1.0;
628            xi_var /= m as f64 - 1.0;
629            let xi_std = xi_var.sqrt();
630
631            let slope = if mu_var < 1e-15 { 1.0 } else { cov / mu_var };
632            let intercept = xi_mean - slope * mu_mean;
633            let corr = if xi_std < 1e-15 || mu_std < 1e-15 {
634                1.0
635            } else {
636                cov / (xi_std * mu_std)
637            };
638
639            ((corr - 1.0).abs(), intercept.abs(), (slope - 1.0).abs())
640        })
641        .collect();
642
643    let mut shape = Vec::with_capacity(n);
644    let mut magnitude = Vec::with_capacity(n);
645    let mut amplitude = Vec::with_capacity(n);
646    for (s, mg, a) in triples {
647        shape.push(s);
648        magnitude.push(mg);
649        amplitude.push(a);
650    }
651    (shape, magnitude, amplitude)
652}
653
654/// Fast-MUOD (Massive Unsupervised Outlier Detection) via per-curve regression on the pointwise mean.
655///
656/// For each curve, regresses it against the sample's pointwise mean μ and forms three indices —
657/// `shape = |corr(X_i, μ) − 1|`, `magnitude = |intercept_i|`, `amplitude = |slope_i − 1|` — then
658/// flags outliers per index with the upper IQR boxplot whisker (`Q3 + factor·IQR`).
659///
660/// **Divergence:** this is the Fast-MUOD variant (regression against the pointwise mean), not
661/// `fdaoutlier`'s pairwise C++ block; results match on the standard synthetic benchmarks. Only the
662/// `boxplot` cutoff is implemented (the `tangent` cutoff is deferred to the backlog). Degenerate
663/// variances (`< 1e-15`) fall back to a neutral index so a constant curve yields no NaN.
664///
665/// # Errors
666/// Returns [`FdarError::InvalidDimension`] if the sample has fewer than 3 curves or fewer than 2 columns.
667#[must_use = "outlier detection results should not be discarded"]
668pub fn muod(data: &FdMatrix, config: MuodConfig) -> Result<MuodResult, FdarError> {
669    let (n, m) = data.shape();
670    if n < 3 {
671        return Err(FdarError::InvalidDimension {
672            parameter: "data",
673            expected: "at least 3 curves".to_string(),
674            actual: format!("{n} rows"),
675        });
676    }
677    if m < 2 {
678        return Err(FdarError::InvalidDimension {
679            parameter: "data",
680            expected: "at least 2 columns".to_string(),
681            actual: format!("{m} columns"),
682        });
683    }
684
685    let (shape_index, magnitude_index, amplitude_index) = muod_indices(data);
686    let flag_upper = |idx: &[f64]| -> Vec<usize> {
687        let (_, upper) = iqr_fence(idx, config.factor);
688        (0..n).filter(|&i| idx[i] > upper).collect()
689    };
690    let shape_outliers = flag_upper(&shape_index);
691    let magnitude_outliers = flag_upper(&magnitude_index);
692    let amplitude_outliers = flag_upper(&amplitude_index);
693
694    Ok(MuodResult {
695        shape_outliers,
696        magnitude_outliers,
697        amplitude_outliers,
698        shape_index,
699        magnitude_index,
700        amplitude_index,
701    })
702}
703
704/// A single step in a [`sequential_transform_outliers`] pipeline.
705///
706/// Transforms are applied **cumulatively** (each step feeds the next):
707/// - `T0` — identity (raw data)
708/// - `T1` — vertical centering: subtract each curve's mean
709/// - `T2` — L2 normalization: divide each curve by its L2 norm
710/// - `D1` — lag-1 first difference (`m → m−1` columns)
711/// - `D2` — identical to `D1` (re-differences when applied after `D1`)
712///
713/// The multivariate outlyingness transform `O` is out of scope for this phase.
714#[derive(Debug, Clone, Copy, PartialEq, Eq)]
715#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
716#[non_exhaustive]
717pub enum SeqTransform {
718    /// Identity (raw data).
719    T0,
720    /// Vertical centering: subtract each curve's mean.
721    T1,
722    /// L2 normalization: divide each curve by its L2 norm.
723    T2,
724    /// Lag-1 first difference.
725    D1,
726    /// Identical to [`SeqTransform::D1`].
727    D2,
728}
729
730/// Configuration for [`sequential_transform_outliers`].
731///
732/// Not `serde`-serializable — it carries a [`DepthMethod`], which does not derive serde.
733#[derive(Debug, Clone, Copy, PartialEq)]
734pub struct SeqTransformConfig {
735    /// Depth method for the per-step functional boxplot. Default [`DepthMethod::ModifiedBand`].
736    pub depth_method: DepthMethod,
737    /// Fence inflation factor for the per-step functional boxplot. Default `1.5`.
738    pub emp_factor: f64,
739}
740
741impl Default for SeqTransformConfig {
742    fn default() -> Self {
743        Self {
744            depth_method: DepthMethod::ModifiedBand,
745            emp_factor: 1.5,
746        }
747    }
748}
749
750/// Numeric output of [`sequential_transform_outliers`] (no rendering).
751#[derive(Debug, Clone, PartialEq)]
752#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
753#[non_exhaustive]
754pub struct SeqTransformOutliers {
755    /// Outlier row indices flagged after each transform step, in sequence order.
756    pub per_transform_outliers: Vec<(SeqTransform, Vec<usize>)>,
757    /// Union of all per-transform outlier indices (sorted, deduplicated).
758    pub union_outliers: Vec<usize>,
759}
760
761/// Apply a single [`SeqTransform`] to a matrix, returning the transformed matrix.
762fn seq_transform_apply(current: &FdMatrix, t: SeqTransform) -> Result<FdMatrix, FdarError> {
763    let (n, m) = current.shape();
764    match t {
765        SeqTransform::T0 => Ok(current.clone()),
766        SeqTransform::T1 => {
767            let mut cm = vec![0.0; n * m];
768            for i in 0..n {
769                let mut mean = 0.0;
770                for j in 0..m {
771                    mean += current[(i, j)];
772                }
773                mean /= m as f64;
774                for j in 0..m {
775                    cm[i + j * n] = current[(i, j)] - mean;
776                }
777            }
778            FdMatrix::from_column_major(cm, n, m)
779        }
780        SeqTransform::T2 => {
781            let mut cm = vec![0.0; n * m];
782            for i in 0..n {
783                let mut norm = 0.0;
784                for j in 0..m {
785                    norm += current[(i, j)].powi(2);
786                }
787                let norm = norm.sqrt();
788                if norm < 1e-15 {
789                    return Err(FdarError::ComputationFailed {
790                        operation: "T2 normalization",
791                        detail: format!("zero-norm curve at row {i}"),
792                    });
793                }
794                for j in 0..m {
795                    cm[i + j * n] = current[(i, j)] / norm;
796                }
797            }
798            FdMatrix::from_column_major(cm, n, m)
799        }
800        SeqTransform::D1 | SeqTransform::D2 => {
801            if m < 2 {
802                return Err(FdarError::InvalidDimension {
803                    parameter: "data",
804                    expected: "at least 2 columns for lag-1 differencing".to_string(),
805                    actual: format!("{m} columns"),
806                });
807            }
808            let m2 = m - 1;
809            let mut cm = vec![0.0; n * m2];
810            for i in 0..n {
811                for k in 0..m2 {
812                    cm[i + k * n] = current[(i, k + 1)] - current[(i, k)];
813                }
814            }
815            FdMatrix::from_column_major(cm, n, m2)
816        }
817    }
818}
819
820/// Sequential-transformation outlier detection (Dai et al. 2020 / `fdaoutlier::seq_transform`).
821///
822/// Applies each transform in `sequence` **cumulatively** (every step's output feeds the next) and
823/// runs a functional boxplot after each step, collecting the per-step outlier sets. The
824/// `union_outliers` field (all indices flagged by at least one step) is an fdars convenience — the R
825/// baseline returns only the per-transform sets.
826///
827/// # Errors
828/// Returns [`FdarError::InvalidDimension`] if the sample has fewer than 2 curves or if a `D1`/`D2`
829/// step is reached with fewer than 2 columns; returns [`FdarError::ComputationFailed`] if a `T2`
830/// step encounters a zero-norm curve.
831#[must_use = "outlier detection results should not be discarded"]
832pub fn sequential_transform_outliers(
833    data: &FdMatrix,
834    sequence: &[SeqTransform],
835    config: SeqTransformConfig,
836) -> Result<SeqTransformOutliers, FdarError> {
837    let n = data.nrows();
838    if n < 2 {
839        return Err(FdarError::InvalidDimension {
840            parameter: "data",
841            expected: "at least 2 curves".to_string(),
842            actual: format!("{n} rows"),
843        });
844    }
845
846    let mut current = data.clone();
847    let mut per_transform_outliers = Vec::with_capacity(sequence.len());
848    for &t in sequence {
849        current = seq_transform_apply(&current, t)?;
850        let fbp = functional_boxplot(&current, config.depth_method, config.emp_factor)?;
851        per_transform_outliers.push((t, fbp.outliers));
852    }
853
854    let mut union_outliers: Vec<usize> = per_transform_outliers
855        .iter()
856        .flat_map(|(_, v)| v.iter().copied())
857        .collect();
858    union_outliers.sort_unstable();
859    union_outliers.dedup();
860
861    Ok(SeqTransformOutliers {
862        per_transform_outliers,
863        union_outliers,
864    })
865}
866
867/// Configuration for [`depthgram`].
868#[derive(Debug, Clone, Copy, PartialEq)]
869#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
870pub struct DepthgramConfig {
871    /// IQR factor for the parabola (shape) upper fence. Default `1.5`.
872    pub outliergram_factor: f64,
873    /// Inflation factor for the MBD magnitude functional boxplot. Default `1.5`.
874    pub boxplot_factor: f64,
875}
876
877impl Default for DepthgramConfig {
878    fn default() -> Self {
879        Self {
880            outliergram_factor: 1.5,
881            boxplot_factor: 1.5,
882        }
883    }
884}
885
886/// Numeric output of the [`depthgram`] statistic (no rendering).
887///
888/// For univariate (p=1) functional data the three representations (dimension-wise `_d`, time-wise
889/// `_t`, correlation-corrected `_t2`) are identical.
890#[derive(Debug, Clone, PartialEq)]
891#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
892#[non_exhaustive]
893pub struct DepthgramResult {
894    /// MBD of the MEI vector, dimension-wise.
895    pub mbd_mei_d: Vec<f64>,
896    /// MEI of the MBD vector, dimension-wise.
897    pub mei_mbd_d: Vec<f64>,
898    /// MBD of the MEI vector, time-wise (equals `_d` for p=1).
899    pub mbd_mei_t: Vec<f64>,
900    /// MEI of the MBD vector, time-wise (equals `_d` for p=1).
901    pub mei_mbd_t: Vec<f64>,
902    /// MBD of the MEI vector, correlation-corrected (equals `_d` for p=1).
903    pub mbd_mei_t2: Vec<f64>,
904    /// MEI of the MBD vector, correlation-corrected (equals `_d` for p=1).
905    pub mei_mbd_t2: Vec<f64>,
906    /// Row indices flagged as shape outliers (upper fence on the parabola deviation).
907    pub shape_outliers: Vec<usize>,
908    /// Row indices flagged as magnitude outliers (functional boxplot on MBD).
909    pub magnitude_outliers: Vec<usize>,
910    /// Modified band depth per curve.
911    pub mbd: Vec<f64>,
912    /// Modified epigraph index per curve.
913    pub mei: Vec<f64>,
914}
915
916/// Depthgram statistic (roahd `depthGram`) — numeric coordinates + outlier flags (no rendering).
917///
918/// Computes the modified band depth (MBD) and modified epigraph index (MEI) of the sample, then the
919/// `(MBD-of-MEI, MEI-of-MBD)` index pairs. Shape outliers are curves whose MBD falls above the
920/// outliergram parabola's upper IQR fence; magnitude outliers come from a functional boxplot on the
921/// MBD values.
922///
923/// **Divergence:** roahd's depthgram is defined for p-variate data and returns three distinct
924/// representations. This implementation handles univariate (p=1) functional data only, where all
925/// three representations are equivalent (the `_d`, `_t`, `_t2` fields are identical). At least ~4
926/// curves are needed for a meaningful parabola IQR.
927///
928/// # Errors
929/// Returns [`FdarError::InvalidDimension`] if the sample has fewer than 2 curves or zero columns.
930#[must_use = "outlier detection results should not be discarded"]
931pub fn depthgram(data: &FdMatrix, config: DepthgramConfig) -> Result<DepthgramResult, FdarError> {
932    let (n, m) = data.shape();
933    if n < 2 || m == 0 {
934        return Err(FdarError::InvalidDimension {
935            parameter: "data",
936            expected: "at least 2 curves and 1 column".to_string(),
937            actual: format!("{n} rows, {m} columns"),
938        });
939    }
940
941    let mbd = modified_band_1d(data, data);
942    let mei = modified_epigraph_index_1d(data, data);
943
944    // (MBD of MEI, MEI of MBD) via n×1 matrix wrapping.
945    let mei_mat = FdMatrix::from_column_major(mei.clone(), n, 1)?;
946    let mbd_mat = FdMatrix::from_column_major(mbd.clone(), n, 1)?;
947    let mbd_mei = modified_band_1d(&mei_mat, &mei_mat);
948    let mei_mbd = modified_epigraph_index_1d(&mbd_mat, &mbd_mat);
949
950    // Shape outliers: deviation above the outliergram parabola, upper IQR fence.
951    let nf = n as f64;
952    let a2 = -2.0 / (nf * (nf - 1.0));
953    let a0 = a2;
954    let a1 = 2.0 * (nf + 1.0) / (nf - 1.0);
955    let dist: Vec<f64> = (0..n)
956        .map(|i| (a0 + a1 * mei[i] + a2 * nf * nf * mei[i] * mei[i]) - mbd[i])
957        .collect();
958    let (_, upper) = iqr_fence(&dist, config.outliergram_factor);
959    let shape_outliers: Vec<usize> = (0..n).filter(|&i| dist[i] > upper).collect();
960
961    // Magnitude outliers: functional boxplot on the MBD values.
962    let mbd_mat2 = FdMatrix::from_column_major(mbd.clone(), n, 1)?;
963    let fbp = functional_boxplot(&mbd_mat2, DepthMethod::ModifiedBand, config.boxplot_factor)?;
964    let magnitude_outliers = fbp.outliers;
965
966    Ok(DepthgramResult {
967        mbd_mei_d: mbd_mei.clone(),
968        mei_mbd_d: mei_mbd.clone(),
969        mbd_mei_t: mbd_mei.clone(),
970        mei_mbd_t: mei_mbd.clone(),
971        mbd_mei_t2: mbd_mei,
972        mei_mbd_t2: mei_mbd,
973        shape_outliers,
974        magnitude_outliers,
975        mbd,
976        mei,
977    })
978}
979
980#[cfg(test)]
981mod tests {
982    use super::*;
983    use std::f64::consts::PI;
984
985    /// Generate homogeneous functional data
986    fn generate_normal_fdata(n: usize, m: usize, seed: u64) -> FdMatrix {
987        let mut rng = StdRng::seed_from_u64(seed);
988        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
989
990        let mut data = FdMatrix::zeros(n, m);
991        for i in 0..n {
992            let phase: f64 = rng.gen::<f64>() * 0.2;
993            let amp: f64 = 1.0 + rng.gen::<f64>() * 0.1;
994            for j in 0..m {
995                let noise: f64 = rng.sample::<f64, _>(StandardNormal) * 0.05;
996                data[(i, j)] = amp * (2.0 * PI * t[j] + phase).sin() + noise;
997            }
998        }
999        data
1000    }
1001
1002    /// Generate data with obvious outliers
1003    fn generate_data_with_outlier(n: usize, m: usize, n_outliers: usize) -> FdMatrix {
1004        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
1005
1006        let mut data = FdMatrix::zeros(n, m);
1007
1008        // Normal curves
1009        for i in 0..(n - n_outliers) {
1010            for j in 0..m {
1011                data[(i, j)] = (2.0 * PI * t[j]).sin();
1012            }
1013        }
1014
1015        // Outlier curves (shifted up by 10)
1016        for i in (n - n_outliers)..n {
1017            for j in 0..m {
1018                data[(i, j)] = (2.0 * PI * t[j]).sin() + 10.0;
1019            }
1020        }
1021
1022        data
1023    }
1024
1025    // ============== Threshold tests ==============
1026
1027    #[test]
1028    fn test_outliers_threshold_lrt_returns_positive() {
1029        let n = 20;
1030        let m = 30;
1031        let data = generate_normal_fdata(n, m, 42);
1032
1033        let threshold = outliers_threshold_lrt(&data, 50, 0.1, 0.1, 42, 0.95);
1034
1035        assert!(threshold > 0.0, "Threshold should be positive");
1036    }
1037
1038    #[test]
1039    fn test_outliers_threshold_lrt_deterministic() {
1040        let n = 15;
1041        let m = 25;
1042        let data = generate_normal_fdata(n, m, 42);
1043
1044        let t1 = outliers_threshold_lrt(&data, 30, 0.1, 0.1, 123, 0.95);
1045        let t2 = outliers_threshold_lrt(&data, 30, 0.1, 0.1, 123, 0.95);
1046
1047        assert!(
1048            (t1 - t2).abs() < 1e-10,
1049            "Same seed should give same threshold"
1050        );
1051    }
1052
1053    #[test]
1054    fn test_outliers_threshold_lrt_percentile_effect() {
1055        let n = 20;
1056        let m = 30;
1057        let data = generate_normal_fdata(n, m, 42);
1058
1059        let t_low = outliers_threshold_lrt(&data, 50, 0.1, 0.1, 42, 0.50);
1060        let t_high = outliers_threshold_lrt(&data, 50, 0.1, 0.1, 42, 0.99);
1061
1062        assert!(
1063            t_high >= t_low,
1064            "Higher percentile should give higher or equal threshold"
1065        );
1066    }
1067
1068    #[test]
1069    fn test_outliers_threshold_lrt_invalid_input() {
1070        // Too few observations
1071        let data = FdMatrix::zeros(2, 30);
1072        let threshold = outliers_threshold_lrt(&data, 50, 0.1, 0.1, 42, 0.95);
1073        assert!(threshold.abs() < 1e-10, "Should return 0 for n < 3");
1074
1075        // Empty m
1076        let data = FdMatrix::zeros(10, 0);
1077        let threshold = outliers_threshold_lrt(&data, 50, 0.1, 0.1, 42, 0.95);
1078        assert!(threshold.abs() < 1e-10);
1079    }
1080
1081    // ============== Detection tests ==============
1082
1083    #[test]
1084    fn test_detect_outliers_lrt_finds_obvious_outlier() {
1085        let n = 20;
1086        let m = 30;
1087        let data = generate_data_with_outlier(n, m, 1);
1088
1089        // Use a reasonable threshold
1090        let outliers = detect_outliers_lrt(&data, 3.0, 0.1);
1091
1092        assert_eq!(outliers.len(), n);
1093
1094        // The last curve (outlier) should be detected
1095        assert!(outliers[n - 1], "Obvious outlier should be detected");
1096
1097        // Most normal curves should not be outliers
1098        let n_detected: usize = outliers.iter().filter(|&&x| x).count();
1099        assert!(n_detected <= 3, "Should not detect too many outliers");
1100    }
1101
1102    #[test]
1103    fn test_detect_outliers_lrt_homogeneous_data() {
1104        let n = 20;
1105        let m = 30;
1106        let data = generate_normal_fdata(n, m, 42);
1107
1108        // With very high threshold, no outliers
1109        let outliers = detect_outliers_lrt(&data, 100.0, 0.1);
1110
1111        let n_detected: usize = outliers.iter().filter(|&&x| x).count();
1112        assert_eq!(
1113            n_detected, 0,
1114            "Very high threshold should detect no outliers"
1115        );
1116    }
1117
1118    #[test]
1119    fn test_detect_outliers_lrt_threshold_effect() {
1120        let n = 20;
1121        let m = 30;
1122        let data = generate_data_with_outlier(n, m, 3);
1123
1124        let low_thresh = detect_outliers_lrt(&data, 2.0, 0.1);
1125        let high_thresh = detect_outliers_lrt(&data, 10.0, 0.1);
1126
1127        let n_low: usize = low_thresh.iter().filter(|&&x| x).count();
1128        let n_high: usize = high_thresh.iter().filter(|&&x| x).count();
1129
1130        assert!(
1131            n_low >= n_high,
1132            "Lower threshold should detect more or equal outliers"
1133        );
1134    }
1135
1136    #[test]
1137    fn test_detect_outliers_lrt_invalid_input() {
1138        // Too few observations
1139        let data = FdMatrix::zeros(2, 30);
1140        let outliers = detect_outliers_lrt(&data, 3.0, 0.1);
1141        assert_eq!(outliers.len(), 2);
1142        assert!(
1143            outliers.iter().all(|&x| !x),
1144            "Should return all false for n < 3"
1145        );
1146    }
1147
1148    #[test]
1149    fn test_identical_data_outliers() {
1150        let n = 10;
1151        let m = 20;
1152        let data = FdMatrix::from_column_major(vec![1.0; n * m], n, m).unwrap();
1153        let flags = detect_outliers_lrt(&data, 1.0, 0.15);
1154        assert_eq!(flags.len(), n);
1155        // All identical → no outliers
1156        for &f in &flags {
1157            assert!(!f);
1158        }
1159    }
1160
1161    #[test]
1162    fn test_n3_minimal_outliers() {
1163        // Minimum viable: 3 curves
1164        let n = 3;
1165        let m = 10;
1166        let mut data_vec = vec![0.0; n * m];
1167        // Third curve is an outlier
1168        for j in 0..m {
1169            data_vec[j * n] = 0.0;
1170            data_vec[1 + j * n] = 0.1;
1171            data_vec[2 + j * n] = 100.0;
1172        }
1173        let data = FdMatrix::from_column_major(data_vec, n, m).unwrap();
1174        let flags = detect_outliers_lrt(&data, 0.5, 0.15);
1175        assert_eq!(flags.len(), n);
1176    }
1177
1178    // ============== With-distribution tests ==============
1179
1180    #[test]
1181    fn test_with_dist_returns_sorted_distribution() {
1182        let data = generate_normal_fdata(20, 30, 42);
1183        let nb = 50;
1184        let (threshold, dist) = outliers_threshold_lrt_with_dist(&data, nb, 0.1, 0.1, 42, 0.95);
1185
1186        assert_eq!(dist.len(), nb, "Distribution length should equal nb");
1187        for w in dist.windows(2) {
1188            assert!(w[0] <= w[1], "Distribution should be sorted");
1189        }
1190        let idx = ((nb as f64 * 0.95) as usize).min(nb - 1);
1191        assert!(
1192            (threshold - dist[idx]).abs() < 1e-10,
1193            "Threshold should match distribution at percentile index"
1194        );
1195    }
1196
1197    #[test]
1198    fn test_with_dist_matches_scalar() {
1199        let data = generate_normal_fdata(15, 25, 99);
1200        let scalar = outliers_threshold_lrt(&data, 40, 0.1, 0.1, 123, 0.95);
1201        let (with_dist, _) = outliers_threshold_lrt_with_dist(&data, 40, 0.1, 0.1, 123, 0.95);
1202        assert!(
1203            (scalar - with_dist).abs() < 1e-10,
1204            "Scalar version should match with_dist version"
1205        );
1206    }
1207
1208    #[test]
1209    fn test_bootstrap_dist_enables_pvalue() {
1210        let n = 20;
1211        let m = 30;
1212        let data = generate_data_with_outlier(n, m, 1);
1213        let trim = 0.1;
1214
1215        let (_, dist) = outliers_threshold_lrt_with_dist(&data, 200, 0.1, trim, 42, 0.99);
1216        let nb = dist.len();
1217
1218        // Compute per-curve distances
1219        let n_keep = ((1.0 - trim) * n as f64).ceil() as usize;
1220        let state = SortedReferenceState::from_reference(&data);
1221        let streaming_fm = StreamingFraimanMuniz::new(state, true);
1222        let depths = streaming_fm.depth_batch(&data);
1223        let (tmean, tvar) = compute_trimmed_stats(&data, &depths, n_keep);
1224
1225        // p-value for the outlier curve (last one)
1226        let d_outlier = normalized_distance(&data, n - 1, &tmean, &tvar);
1227        let p_outlier =
1228            (dist.iter().filter(|&&v| v >= d_outlier).count() as f64 + 1.0) / (nb as f64 + 1.0);
1229
1230        // p-value for a normal curve (first one)
1231        let d_normal = normalized_distance(&data, 0, &tmean, &tvar);
1232        let p_normal =
1233            (dist.iter().filter(|&&v| v >= d_normal).count() as f64 + 1.0) / (nb as f64 + 1.0);
1234
1235        assert!(
1236            p_outlier < 0.05,
1237            "Outlier should have small p-value, got {p_outlier}"
1238        );
1239        assert!(
1240            p_normal > 0.05,
1241            "Normal curve should have large p-value, got {p_normal}"
1242        );
1243    }
1244
1245    #[test]
1246    fn test_with_dist_invalid_input() {
1247        let data = FdMatrix::zeros(2, 30);
1248        let (threshold, dist) = outliers_threshold_lrt_with_dist(&data, 50, 0.1, 0.1, 42, 0.95);
1249        assert!(threshold.abs() < 1e-10);
1250        assert!(dist.is_empty(), "Should return empty dist for n < 3");
1251    }
1252
1253    #[test]
1254    fn test_all_false_high_threshold() {
1255        let n = 10;
1256        let m = 20;
1257        let data_vec: Vec<f64> = (0..n * m).map(|i| (i as f64 * 0.1).sin()).collect();
1258        let data = FdMatrix::from_column_major(data_vec, n, m).unwrap();
1259        // Very high threshold → no outliers
1260        let flags = detect_outliers_lrt(&data, 1e10, 0.15);
1261        for &f in &flags {
1262            assert!(!f, "High threshold should produce no outliers");
1263        }
1264    }
1265
1266    // ============== n_keep clamping tests ==============
1267
1268    #[test]
1269    fn test_trim_zero_no_trimming() {
1270        // trim=0 → n_keep=n, exercises the skip-partial-sort branch in compute_trimmed_stats
1271        let data = generate_normal_fdata(10, 20, 42);
1272        let threshold = outliers_threshold_lrt(&data, 30, 0.1, 0.0, 42, 0.95);
1273        assert!(threshold > 0.0);
1274        let flags = detect_outliers_lrt(&data, threshold, 0.0);
1275        assert_eq!(flags.len(), 10);
1276    }
1277
1278    #[test]
1279    fn test_trim_near_one_heavy_trimming() {
1280        // trim=0.9 → n_keep=1, exercises minimal trim set (single deepest curve)
1281        let data = generate_normal_fdata(10, 20, 42);
1282        let threshold = outliers_threshold_lrt(&data, 30, 0.1, 0.9, 42, 0.95);
1283        assert!(threshold >= 0.0);
1284        let flags = detect_outliers_lrt(&data, threshold, 0.9);
1285        assert_eq!(flags.len(), 10);
1286    }
1287
1288    #[test]
1289    fn test_trim_one_clamps_to_one() {
1290        // trim=1.0 → n_keep would be 0, must clamp to 1 (was a panic before fix)
1291        let data = generate_normal_fdata(10, 20, 42);
1292        let threshold = outliers_threshold_lrt(&data, 30, 0.1, 1.0, 42, 0.95);
1293        assert!(threshold >= 0.0);
1294        let flags = detect_outliers_lrt(&data, threshold, 1.0);
1295        assert_eq!(flags.len(), 10);
1296    }
1297
1298    #[test]
1299    fn test_trim_negative_clamps_to_n() {
1300        // trim=-0.5 → n_keep would exceed n, must clamp to n (was a panic before fix)
1301        let data = generate_normal_fdata(10, 20, 42);
1302        let threshold = outliers_threshold_lrt(&data, 30, 0.1, -0.5, 42, 0.95);
1303        assert!(threshold > 0.0);
1304        let flags = detect_outliers_lrt(&data, threshold, -0.5);
1305        assert_eq!(flags.len(), 10);
1306    }
1307
1308    // ============== Bootstrap parameter edge cases ==============
1309
1310    #[test]
1311    fn test_smo_zero_no_noise() {
1312        // smo=0 → bootstrap resamples without smoothing noise
1313        let data = generate_normal_fdata(10, 20, 42);
1314        let (threshold, dist) = outliers_threshold_lrt_with_dist(&data, 30, 0.0, 0.1, 42, 0.95);
1315        assert!(threshold > 0.0);
1316        assert_eq!(dist.len(), 30);
1317    }
1318
1319    #[test]
1320    fn test_nb_zero_empty_bootstrap() {
1321        let data = generate_normal_fdata(10, 20, 42);
1322        let (threshold, dist) = outliers_threshold_lrt_with_dist(&data, 0, 0.1, 0.1, 42, 0.95);
1323        assert!(threshold.abs() < 1e-10);
1324        assert!(dist.is_empty());
1325    }
1326
1327    #[test]
1328    fn test_nb_one_single_bootstrap() {
1329        let data = generate_normal_fdata(10, 20, 42);
1330        let (threshold, dist) = outliers_threshold_lrt_with_dist(&data, 1, 0.1, 0.1, 42, 0.95);
1331        assert_eq!(dist.len(), 1);
1332        // With 1 iteration, threshold must equal the single value
1333        assert!((threshold - dist[0]).abs() < 1e-10);
1334    }
1335
1336    #[test]
1337    fn test_percentile_zero_returns_minimum() {
1338        let data = generate_normal_fdata(15, 20, 42);
1339        let nb = 50;
1340        let (_, dist) = outliers_threshold_lrt_with_dist(&data, nb, 0.1, 0.1, 42, 0.95);
1341        let t_zero = outliers_threshold_lrt(&data, nb, 0.1, 0.1, 42, 0.0);
1342        assert!(
1343            (t_zero - dist[0]).abs() < 1e-10,
1344            "percentile=0 should return the minimum of the distribution"
1345        );
1346    }
1347
1348    #[test]
1349    fn test_percentile_one_returns_maximum() {
1350        let data = generate_normal_fdata(15, 20, 42);
1351        let nb = 50;
1352        let (_, dist) = outliers_threshold_lrt_with_dist(&data, nb, 0.1, 0.1, 42, 0.95);
1353        let t_one = outliers_threshold_lrt(&data, nb, 0.1, 0.1, 42, 1.0);
1354        assert!(
1355            (t_one - *dist.last().unwrap()).abs() < 1e-10,
1356            "percentile=1 should return the maximum of the distribution"
1357        );
1358    }
1359
1360    // ============== Distribution invariant tests ==============
1361
1362    #[test]
1363    fn test_distribution_values_non_negative() {
1364        let data = generate_normal_fdata(15, 20, 42);
1365        let (_, dist) = outliers_threshold_lrt_with_dist(&data, 50, 0.1, 0.1, 42, 0.95);
1366        for &v in &dist {
1367            assert!(v >= 0.0, "Max-distances must be non-negative, got {v}");
1368        }
1369    }
1370
1371    // ============== detect_outliers_lrt edge cases ==============
1372
1373    #[test]
1374    fn test_detect_m_zero_returns_all_false() {
1375        let data = FdMatrix::zeros(10, 0);
1376        let flags = detect_outliers_lrt(&data, 3.0, 0.1);
1377        assert_eq!(flags.len(), 10);
1378        assert!(flags.iter().all(|&f| !f));
1379    }
1380
1381    #[test]
1382    fn test_detect_multiple_outliers() {
1383        let data = generate_data_with_outlier(20, 30, 3);
1384        let flags = detect_outliers_lrt(&data, 3.0, 0.1);
1385        // All three outlier curves (indices 17, 18, 19) should be detected
1386        let outlier_count = flags[17..20].iter().filter(|&&x| x).count();
1387        assert!(
1388            outlier_count >= 2,
1389            "At least 2 of 3 outliers should be detected, got {outlier_count}"
1390        );
1391    }
1392
1393    // ============== End-to-end integration ==============
1394
1395    #[test]
1396    fn test_end_to_end_threshold_then_detect() {
1397        let data = generate_data_with_outlier(20, 30, 2);
1398        let threshold = outliers_threshold_lrt(&data, 100, 0.1, 0.1, 42, 0.99);
1399        let flags = detect_outliers_lrt(&data, threshold, 0.1);
1400
1401        // Outlier curves (last 2) should be flagged
1402        assert!(
1403            flags[18] || flags[19],
1404            "At least one outlier should be detected in end-to-end flow"
1405        );
1406        // Normal curves should mostly not be flagged
1407        let false_positives = flags[..18].iter().filter(|&&x| x).count();
1408        assert!(
1409            false_positives <= 2,
1410            "False positive count should be low, got {false_positives}"
1411        );
1412    }
1413
1414    #[test]
1415    fn test_end_to_end_with_dist_pvalues_all_curves() {
1416        // Full pipeline: bootstrap dist → per-curve p-values → outlier classification
1417        let n = 25;
1418        let m = 30;
1419        let data = generate_data_with_outlier(n, m, 2);
1420        let trim = 0.1;
1421
1422        let (_, dist) = outliers_threshold_lrt_with_dist(&data, 200, 0.1, trim, 42, 0.99);
1423        let nb = dist.len();
1424
1425        let n_keep = ((1.0 - trim) * n as f64).ceil().max(1.0) as usize;
1426        let n_keep = n_keep.min(n);
1427        let state = SortedReferenceState::from_reference(&data);
1428        let streaming_fm = StreamingFraimanMuniz::new(state, true);
1429        let depths = streaming_fm.depth_batch(&data);
1430        let (tmean, tvar) = compute_trimmed_stats(&data, &depths, n_keep);
1431
1432        // Compute p-values for all curves
1433        let pvalues: Vec<f64> = (0..n)
1434            .map(|i| {
1435                let d = normalized_distance(&data, i, &tmean, &tvar);
1436                (dist.iter().filter(|&&v| v >= d).count() as f64 + 1.0) / (nb as f64 + 1.0)
1437            })
1438            .collect();
1439
1440        // Normal curves (0..23) should have large p-values
1441        let normal_small_p = pvalues[..23].iter().filter(|&&p| p < 0.01).count();
1442        assert_eq!(
1443            normal_small_p, 0,
1444            "Normal curves should not have tiny p-values"
1445        );
1446
1447        // Outlier curves (23, 24) should have small p-values
1448        for &i in &[23, 24] {
1449            assert!(
1450                pvalues[i] < 0.05,
1451                "Outlier curve {i} should have small p-value, got {}",
1452                pvalues[i]
1453            );
1454        }
1455    }
1456
1457    // ============== Outliergram tests ==============
1458
1459    fn outliergram_test_data() -> FdMatrix {
1460        // 20 curves on 30 grid points, with 2 outliers
1461        let n = 20;
1462        let m = 30;
1463        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
1464        let mut vals = vec![0.0; n * m];
1465        for i in 0..n {
1466            for (j, &tj) in t.iter().enumerate() {
1467                let base = tj.sin();
1468                vals[i + j * n] = if i < 18 {
1469                    base + 0.1 * (i as f64 * 0.5).sin()
1470                } else {
1471                    // Outliers: large deviation
1472                    base + 2.0 * (if i == 18 { 1.0 } else { -1.0 })
1473                };
1474            }
1475        }
1476        FdMatrix::from_column_major(vals, n, m).unwrap()
1477    }
1478
1479    #[test]
1480    fn outliergram_runs() {
1481        let data = outliergram_test_data();
1482        let result = outliergram(&data, 1.5).unwrap();
1483        assert_eq!(result.mei.len(), 20);
1484        assert_eq!(result.mbd.len(), 20);
1485        assert_eq!(result.outlier_flags.len(), 20);
1486        // The two outlier curves should have lower MBD
1487        let central_mbd: f64 = result.mbd[..18].iter().sum::<f64>() / 18.0;
1488        assert!(result.mbd[18] < central_mbd || result.mbd[19] < central_mbd);
1489    }
1490
1491    #[test]
1492    fn outliergram_parabola_coefficients() {
1493        let data = outliergram_test_data();
1494        let result = outliergram(&data, 1.5).unwrap();
1495        // Parabola should have a2 <= 0 (concave) for the theoretical relationship
1496        // (this is typical but not strictly required)
1497        assert!(result.a0.is_finite());
1498        assert!(result.a1.is_finite());
1499        assert!(result.a2.is_finite());
1500    }
1501
1502    #[test]
1503    fn magnitude_shape_dimensions() {
1504        let data = outliergram_test_data();
1505        let result = magnitude_shape_outlyingness(&data).unwrap();
1506        assert_eq!(result.magnitude.len(), 20);
1507        assert_eq!(result.shape.len(), 20);
1508        // All values should be non-negative
1509        assert!(result.magnitude.iter().all(|&v| v >= 0.0));
1510        assert!(result.shape.iter().all(|&v| v >= 0.0));
1511    }
1512
1513    #[test]
1514    fn magnitude_outliers_have_high_magnitude() {
1515        let data = outliergram_test_data();
1516        let result = magnitude_shape_outlyingness(&data).unwrap();
1517        let central_mag: f64 = result.magnitude[..18].iter().sum::<f64>() / 18.0;
1518        // At least one outlier should have higher magnitude
1519        assert!(result.magnitude[18] > central_mag || result.magnitude[19] > central_mag);
1520    }
1521
1522    #[test]
1523    fn outliergram_too_few_curves() {
1524        let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0], 2, 2).unwrap();
1525        assert!(outliergram(&data, 1.5).is_err());
1526    }
1527
1528    // --- Phase 29: tvdmss + muod ---
1529
1530    /// `n` sinusoids each with a genuine per-curve shape perturbation (so the derivative
1531    /// ranks that drive MSS are well-defined, not floating-point noise). `outlier_idx` is
1532    /// replaced per `kind`: "magnitude" = same shape +10 vertical shift, "amplitude" = ×5
1533    /// scale, "shape" = a genuinely different high-frequency wiggle, "constant" = a flat curve.
1534    fn outlier_sample(n: usize, m: usize, outlier_idx: usize, kind: &str) -> FdMatrix {
1535        // A normal inlier-shaped curve for row i: a primary sine plus a small curve-specific
1536        // secondary harmonic that gives each curve real (non-degenerate) derivative variation.
1537        let inlier_shape = |i: usize, x: f64| -> f64 {
1538            (x * PI).sin() + 0.1 * (x * 4.0 * PI + 0.3 * i as f64).sin()
1539        };
1540        let mut cm = vec![0.0; n * m];
1541        for i in 0..n {
1542            for t in 0..m {
1543                let x = t as f64 / (m as f64 - 1.0);
1544                let val = if i == outlier_idx {
1545                    match kind {
1546                        // Same shape family as its own inlier, only shifted / scaled.
1547                        "magnitude" => inlier_shape(i, x) + 10.0,
1548                        "amplitude" => 5.0 * inlier_shape(i, x),
1549                        // A genuinely different shape: centered on the sin trend (same
1550                        // vertical band as the inliers) but with a high-frequency wiggle
1551                        // that weaves through the bundle — low MBD, mid MEI, low MSS.
1552                        "shape" => (x * PI).sin() + 0.4 * (x * 12.0 * PI).sin(),
1553                        "constant" => 0.5,
1554                        _ => inlier_shape(i, x),
1555                    }
1556                } else {
1557                    inlier_shape(i, x)
1558                };
1559                cm[i + t * n] = val;
1560            }
1561        }
1562        FdMatrix::from_column_major(cm, n, m).unwrap()
1563    }
1564
1565    #[test]
1566    fn tvdmss_flags_magnitude_outlier() {
1567        let idx = 4usize;
1568        let data = outlier_sample(12, 40, idx, "magnitude");
1569        let res = tvdmss(&data, TvdMssConfig::default()).unwrap();
1570        assert_eq!(res.tvd.len(), 12);
1571        assert_eq!(res.mss.len(), 12);
1572        assert!(
1573            res.magnitude_outliers.contains(&idx),
1574            "magnitude outlier {idx} not flagged: {:?}",
1575            res.magnitude_outliers
1576        );
1577    }
1578
1579    #[test]
1580    fn tvdmss_flags_shape_outlier() {
1581        let idx = 7usize;
1582        let data = outlier_sample(12, 60, idx, "shape");
1583        let res = tvdmss(&data, TvdMssConfig::default()).unwrap();
1584        assert!(
1585            res.shape_outliers.contains(&idx),
1586            "shape outlier {idx} not flagged: {:?}",
1587            res.shape_outliers
1588        );
1589    }
1590
1591    #[test]
1592    fn tvdmss_rejects_empty_and_too_few() {
1593        let empty = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
1594        assert!(matches!(
1595            tvdmss(&empty, TvdMssConfig::default()),
1596            Err(FdarError::InvalidDimension { .. })
1597        ));
1598        let two = outlier_sample(2, 8, 0, "none");
1599        assert!(matches!(
1600            tvdmss(&two, TvdMssConfig::default()),
1601            Err(FdarError::InvalidDimension { .. })
1602        ));
1603    }
1604
1605    #[test]
1606    fn muod_flags_magnitude_amplitude_shape() {
1607        let mag = outlier_sample(12, 40, 3, "magnitude");
1608        let r = muod(&mag, MuodConfig::default()).unwrap();
1609        assert_eq!(r.shape_index.len(), 12);
1610        assert!(
1611            r.magnitude_outliers.contains(&3),
1612            "magnitude: {:?}",
1613            r.magnitude_outliers
1614        );
1615
1616        let amp = outlier_sample(12, 40, 5, "amplitude");
1617        let r = muod(&amp, MuodConfig::default()).unwrap();
1618        assert!(
1619            r.amplitude_outliers.contains(&5),
1620            "amplitude: {:?}",
1621            r.amplitude_outliers
1622        );
1623
1624        let shp = outlier_sample(12, 60, 8, "shape");
1625        let r = muod(&shp, MuodConfig::default()).unwrap();
1626        assert!(
1627            r.shape_outliers.contains(&8),
1628            "shape: {:?}",
1629            r.shape_outliers
1630        );
1631    }
1632
1633    #[test]
1634    fn muod_constant_curve_no_nan() {
1635        let data = outlier_sample(12, 40, 6, "constant");
1636        let r = muod(&data, MuodConfig::default()).unwrap();
1637        for v in r
1638            .shape_index
1639            .iter()
1640            .chain(&r.magnitude_index)
1641            .chain(&r.amplitude_index)
1642        {
1643            assert!(!v.is_nan(), "index produced NaN");
1644        }
1645    }
1646
1647    #[test]
1648    fn muod_rejects_bad_dims() {
1649        let two = outlier_sample(2, 8, 0, "none");
1650        assert!(matches!(
1651            muod(&two, MuodConfig::default()),
1652            Err(FdarError::InvalidDimension { .. })
1653        ));
1654        let one_col = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 3, 1).unwrap();
1655        assert!(matches!(
1656            muod(&one_col, MuodConfig::default()),
1657            Err(FdarError::InvalidDimension { .. })
1658        ));
1659    }
1660
1661    // --- Phase 29 Plan 02: sequential_transform_outliers + depthgram ---
1662
1663    #[test]
1664    fn seq_transform_default_sequence_flags_outlier_and_union_is_flatten() {
1665        let idx = 4usize;
1666        let data = outlier_sample(12, 40, idx, "magnitude");
1667        let seq = [SeqTransform::T0, SeqTransform::T1, SeqTransform::D1];
1668        let res =
1669            sequential_transform_outliers(&data, &seq, SeqTransformConfig::default()).unwrap();
1670
1671        assert_eq!(res.per_transform_outliers.len(), 3);
1672        assert!(
1673            res.per_transform_outliers
1674                .iter()
1675                .any(|(_, v)| !v.is_empty()),
1676            "no transform flagged anything"
1677        );
1678        assert!(
1679            res.union_outliers.contains(&idx),
1680            "union {:?} missing outlier {idx}",
1681            res.union_outliers
1682        );
1683        // union == sorted, deduped flatten of the per-transform sets.
1684        let mut expected: Vec<usize> = res
1685            .per_transform_outliers
1686            .iter()
1687            .flat_map(|(_, v)| v.iter().copied())
1688            .collect();
1689        expected.sort_unstable();
1690        expected.dedup();
1691        assert_eq!(res.union_outliers, expected);
1692    }
1693
1694    #[test]
1695    fn seq_transform_error_paths() {
1696        // D1 with a single column.
1697        let one_col = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 3, 1).unwrap();
1698        assert!(matches!(
1699            sequential_transform_outliers(
1700                &one_col,
1701                &[SeqTransform::D1],
1702                SeqTransformConfig::default()
1703            ),
1704            Err(FdarError::InvalidDimension { .. })
1705        ));
1706        // T2 with an all-zero curve → ComputationFailed.
1707        let mut cm = vec![0.0; 3 * 4];
1708        for i in [0usize, 2] {
1709            for t in 0..4 {
1710                cm[i + t * 3] = 1.0 + i as f64 + t as f64;
1711            }
1712        }
1713        // row 1 stays all-zero
1714        let zero_row = FdMatrix::from_column_major(cm, 3, 4).unwrap();
1715        assert!(matches!(
1716            sequential_transform_outliers(
1717                &zero_row,
1718                &[SeqTransform::T2],
1719                SeqTransformConfig::default()
1720            ),
1721            Err(FdarError::ComputationFailed { .. })
1722        ));
1723        // n == 1.
1724        let one_curve = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
1725        assert!(matches!(
1726            sequential_transform_outliers(
1727                &one_curve,
1728                &[SeqTransform::T0],
1729                SeqTransformConfig::default()
1730            ),
1731            Err(FdarError::InvalidDimension { .. })
1732        ));
1733    }
1734
1735    #[test]
1736    fn depthgram_flags_magnitude_and_shape() {
1737        let mag = outlier_sample(12, 40, 3, "magnitude");
1738        let r = depthgram(&mag, DepthgramConfig::default()).unwrap();
1739        assert_eq!(r.mbd.len(), 12);
1740        assert_eq!(r.mei.len(), 12);
1741        assert_eq!(r.mbd_mei_d.len(), 12);
1742        assert!(
1743            r.magnitude_outliers.contains(&3),
1744            "magnitude: {:?}",
1745            r.magnitude_outliers
1746        );
1747
1748        let shp = outlier_sample(14, 60, 9, "shape");
1749        let r = depthgram(&shp, DepthgramConfig::default()).unwrap();
1750        assert!(
1751            r.shape_outliers.contains(&9),
1752            "shape: {:?}",
1753            r.shape_outliers
1754        );
1755    }
1756
1757    #[test]
1758    fn depthgram_p1_representations_equivalent() {
1759        let data = outlier_sample(10, 30, 2, "magnitude");
1760        let r = depthgram(&data, DepthgramConfig::default()).unwrap();
1761        assert_eq!(r.mbd_mei_d, r.mbd_mei_t);
1762        assert_eq!(r.mbd_mei_d, r.mbd_mei_t2);
1763        assert_eq!(r.mei_mbd_d, r.mei_mbd_t);
1764        assert_eq!(r.mei_mbd_d, r.mei_mbd_t2);
1765    }
1766
1767    #[test]
1768    fn depthgram_rejects_bad_dims() {
1769        let one = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
1770        assert!(matches!(
1771            depthgram(&one, DepthgramConfig::default()),
1772            Err(FdarError::InvalidDimension { .. })
1773        ));
1774        let empty = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
1775        assert!(matches!(
1776            depthgram(&empty, DepthgramConfig::default()),
1777            Err(FdarError::InvalidDimension { .. })
1778        ));
1779    }
1780}