1use 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
18fn 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 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
60fn 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#[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#[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 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 let var = sum_sq / n as f64 - mean * mean;
149 var.max(0.0).sqrt()
150 })
151 .collect();
152
153 let max_dists: Vec<f64> = iter_maybe_parallel!(0..nb)
155 .map(|b| {
156 let mut rng = crate::helpers::seed_for_thread(seed, b);
157
158 let indices: Vec<usize> = (0..n).map(|_| rng.gen_range(0..n)).collect();
160 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 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 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 (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 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#[must_use = "expensive computation whose result should not be discarded"]
206pub 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#[derive(Debug, Clone, PartialEq)]
252#[non_exhaustive]
253pub struct OutligramResult {
254 pub mei: Vec<f64>,
256 pub mbd: Vec<f64>,
258 pub a0: f64,
260 pub a1: f64,
261 pub a2: f64,
262 pub threshold: f64,
264 pub outlier_flags: Vec<bool>,
266}
267
268pub 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 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 let (a0, a1, a2) = solve_3x3(xtx, xty);
309
310 let residuals: Vec<f64> = (0..n)
312 .map(|i| mbd[i] - (a0 + a1 * mei[i] + a2 * mei[i] * mei[i]))
313 .collect();
314
315 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#[derive(Debug, Clone, PartialEq)]
338#[non_exhaustive]
339pub struct MagnitudeShapeResult {
340 pub magnitude: Vec<f64>,
342 pub shape: Vec<f64>,
344}
345
346pub 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 let mbd = modified_band_1d(data, data);
366 let magnitude: Vec<f64> = mbd.iter().map(|&d| 1.0 - d).collect();
367
368 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 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 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 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
426fn 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
450fn 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#[derive(Debug, Clone, Copy, PartialEq)]
466#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
467pub struct TvdMssConfig {
468 pub emp_factor_mss: f64,
470 pub emp_factor_tvd: f64,
472 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#[derive(Debug, Clone, PartialEq)]
490#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
491#[non_exhaustive]
492pub struct TvdMssOutliers {
493 pub magnitude_outliers: Vec<usize>,
495 pub shape_outliers: Vec<usize>,
497 pub tvd: Vec<f64>,
499 pub mss: Vec<f64>,
501}
502
503#[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 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 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#[derive(Debug, Clone, Copy, PartialEq)]
563#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
564pub struct MuodConfig {
565 pub factor: f64,
567}
568
569impl Default for MuodConfig {
570 fn default() -> Self {
571 Self { factor: 1.5 }
572 }
573}
574
575#[derive(Debug, Clone, PartialEq)]
577#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
578#[non_exhaustive]
579pub struct MuodResult {
580 pub shape_outliers: Vec<usize>,
582 pub magnitude_outliers: Vec<usize>,
584 pub amplitude_outliers: Vec<usize>,
586 pub shape_index: Vec<f64>,
588 pub magnitude_index: Vec<f64>,
590 pub amplitude_index: Vec<f64>,
592}
593
594fn muod_indices(data: &FdMatrix) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
596 let (n, m) = data.shape();
597
598 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#[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(&litude_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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
715#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
716#[non_exhaustive]
717pub enum SeqTransform {
718 T0,
720 T1,
722 T2,
724 D1,
726 D2,
728}
729
730#[derive(Debug, Clone, Copy, PartialEq)]
734pub struct SeqTransformConfig {
735 pub depth_method: DepthMethod,
737 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#[derive(Debug, Clone, PartialEq)]
752#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
753#[non_exhaustive]
754pub struct SeqTransformOutliers {
755 pub per_transform_outliers: Vec<(SeqTransform, Vec<usize>)>,
757 pub union_outliers: Vec<usize>,
759}
760
761fn 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#[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(¤t, t)?;
850 let fbp = functional_boxplot(¤t, 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#[derive(Debug, Clone, Copy, PartialEq)]
869#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
870pub struct DepthgramConfig {
871 pub outliergram_factor: f64,
873 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#[derive(Debug, Clone, PartialEq)]
891#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
892#[non_exhaustive]
893pub struct DepthgramResult {
894 pub mbd_mei_d: Vec<f64>,
896 pub mei_mbd_d: Vec<f64>,
898 pub mbd_mei_t: Vec<f64>,
900 pub mei_mbd_t: Vec<f64>,
902 pub mbd_mei_t2: Vec<f64>,
904 pub mei_mbd_t2: Vec<f64>,
906 pub shape_outliers: Vec<usize>,
908 pub magnitude_outliers: Vec<usize>,
910 pub mbd: Vec<f64>,
912 pub mei: Vec<f64>,
914}
915
916#[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 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 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 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 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 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 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 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 #[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 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 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 #[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 let outliers = detect_outliers_lrt(&data, 3.0, 0.1);
1091
1092 assert_eq!(outliers.len(), n);
1093
1094 assert!(outliers[n - 1], "Obvious outlier should be detected");
1096
1097 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 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 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 for &f in &flags {
1157 assert!(!f);
1158 }
1159 }
1160
1161 #[test]
1162 fn test_n3_minimal_outliers() {
1163 let n = 3;
1165 let m = 10;
1166 let mut data_vec = vec![0.0; n * m];
1167 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 #[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 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 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 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 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 #[test]
1269 fn test_trim_zero_no_trimming() {
1270 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 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 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 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 #[test]
1311 fn test_smo_zero_no_noise() {
1312 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 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 #[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 #[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 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 #[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 assert!(
1403 flags[18] || flags[19],
1404 "At least one outlier should be detected in end-to-end flow"
1405 );
1406 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 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 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 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 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 fn outliergram_test_data() -> FdMatrix {
1460 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 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 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 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 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 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 fn outlier_sample(n: usize, m: usize, outlier_idx: usize, kind: &str) -> FdMatrix {
1535 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 "magnitude" => inlier_shape(i, x) + 10.0,
1548 "amplitude" => 5.0 * inlier_shape(i, x),
1549 "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(&, 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 #[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 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 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 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 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 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}