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#[non_exhaustive]
468#[derive(Debug, Clone, Copy, PartialEq)]
469#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
470pub struct TvdMssConfig {
471 pub emp_factor_mss: f64,
473 pub emp_factor_tvd: f64,
475 pub central_region_tvd: f64,
479}
480
481impl Default for TvdMssConfig {
482 fn default() -> Self {
483 Self {
484 emp_factor_mss: 1.5,
485 emp_factor_tvd: 1.5,
486 central_region_tvd: 0.5,
487 }
488 }
489}
490
491#[derive(Debug, Clone, PartialEq)]
493#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
494#[non_exhaustive]
495pub struct TvdMssOutliers {
496 pub magnitude_outliers: Vec<usize>,
498 pub shape_outliers: Vec<usize>,
500 pub tvd: Vec<f64>,
502 pub mss: Vec<f64>,
504}
505
506#[must_use = "outlier detection results should not be discarded"]
521pub fn tvdmss(data: &FdMatrix, config: TvdMssConfig) -> Result<TvdMssOutliers, FdarError> {
522 let (n, m) = data.shape();
523 if n < 3 || m == 0 {
524 return Err(FdarError::InvalidDimension {
525 parameter: "data",
526 expected: "at least 3 curves and 1 column".to_string(),
527 actual: format!("{n} rows, {m} columns"),
528 });
529 }
530
531 let depth = total_variation_depth_1d(data, data)?;
532
533 let (lower_mss, _) = iqr_fence(&depth.mss, config.emp_factor_mss);
535 let mean_mss = depth.mss.iter().sum::<f64>() / n as f64;
536 let shape_outliers: Vec<usize> = (0..n)
537 .filter(|&i| depth.mss[i] < lower_mss && depth.mss[i] < mean_mss)
538 .collect();
539
540 let keep: Vec<usize> = (0..n).filter(|i| !shape_outliers.contains(i)).collect();
542 let mut magnitude_outliers = Vec::new();
543 if keep.len() >= 3 {
544 let kn = keep.len();
545 let mut col_major = vec![0.0; kn * m];
546 for (r, &orig) in keep.iter().enumerate() {
547 for j in 0..m {
548 col_major[r + j * kn] = data[(orig, j)];
549 }
550 }
551 let reduced = FdMatrix::from_column_major(col_major, kn, m)?;
552 let fbp = functional_boxplot(&reduced, DepthMethod::ModifiedBand, config.emp_factor_tvd)?;
553 magnitude_outliers = fbp.outliers.iter().map(|&r| keep[r]).collect();
554 }
555
556 Ok(TvdMssOutliers {
557 magnitude_outliers,
558 shape_outliers,
559 tvd: depth.tvd,
560 mss: depth.mss,
561 })
562}
563
564#[non_exhaustive]
568#[derive(Debug, Clone, Copy, PartialEq)]
569#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
570pub struct MuodConfig {
571 pub factor: f64,
573}
574
575impl Default for MuodConfig {
576 fn default() -> Self {
577 Self { factor: 1.5 }
578 }
579}
580
581#[derive(Debug, Clone, PartialEq)]
583#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
584#[non_exhaustive]
585pub struct MuodResult {
586 pub shape_outliers: Vec<usize>,
588 pub magnitude_outliers: Vec<usize>,
590 pub amplitude_outliers: Vec<usize>,
592 pub shape_index: Vec<f64>,
594 pub magnitude_index: Vec<f64>,
596 pub amplitude_index: Vec<f64>,
598}
599
600fn muod_indices(data: &FdMatrix) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
602 let (n, m) = data.shape();
603
604 let mut mu = vec![0.0; m];
606 for (j, mu_j) in mu.iter_mut().enumerate() {
607 let mut s = 0.0;
608 for i in 0..n {
609 s += data[(i, j)];
610 }
611 *mu_j = s / n as f64;
612 }
613 let mu_mean = mu.iter().sum::<f64>() / m as f64;
614 let mu_var = mu.iter().map(|&v| (v - mu_mean).powi(2)).sum::<f64>() / (m as f64 - 1.0);
615 let mu_std = mu_var.sqrt();
616
617 let triples: Vec<(f64, f64, f64)> = iter_maybe_parallel!(0..n)
618 .map(|i| {
619 let mut xi_mean = 0.0;
620 for j in 0..m {
621 xi_mean += data[(i, j)];
622 }
623 xi_mean /= m as f64;
624
625 let mut cov = 0.0;
626 let mut xi_var = 0.0;
627 for j in 0..m {
628 let dx = data[(i, j)] - xi_mean;
629 let dmu = mu[j] - mu_mean;
630 cov += dx * dmu;
631 xi_var += dx * dx;
632 }
633 cov /= m as f64 - 1.0;
634 xi_var /= m as f64 - 1.0;
635 let xi_std = xi_var.sqrt();
636
637 let slope = if mu_var < 1e-15 { 1.0 } else { cov / mu_var };
638 let intercept = xi_mean - slope * mu_mean;
639 let corr = if xi_std < 1e-15 || mu_std < 1e-15 {
640 1.0
641 } else {
642 cov / (xi_std * mu_std)
643 };
644
645 ((corr - 1.0).abs(), intercept.abs(), (slope - 1.0).abs())
646 })
647 .collect();
648
649 let mut shape = Vec::with_capacity(n);
650 let mut magnitude = Vec::with_capacity(n);
651 let mut amplitude = Vec::with_capacity(n);
652 for (s, mg, a) in triples {
653 shape.push(s);
654 magnitude.push(mg);
655 amplitude.push(a);
656 }
657 (shape, magnitude, amplitude)
658}
659
660#[must_use = "outlier detection results should not be discarded"]
674pub fn muod(data: &FdMatrix, config: MuodConfig) -> Result<MuodResult, FdarError> {
675 let (n, m) = data.shape();
676 if n < 3 {
677 return Err(FdarError::InvalidDimension {
678 parameter: "data",
679 expected: "at least 3 curves".to_string(),
680 actual: format!("{n} rows"),
681 });
682 }
683 if m < 2 {
684 return Err(FdarError::InvalidDimension {
685 parameter: "data",
686 expected: "at least 2 columns".to_string(),
687 actual: format!("{m} columns"),
688 });
689 }
690
691 let (shape_index, magnitude_index, amplitude_index) = muod_indices(data);
692 let flag_upper = |idx: &[f64]| -> Vec<usize> {
693 let (_, upper) = iqr_fence(idx, config.factor);
694 (0..n).filter(|&i| idx[i] > upper).collect()
695 };
696 let shape_outliers = flag_upper(&shape_index);
697 let magnitude_outliers = flag_upper(&magnitude_index);
698 let amplitude_outliers = flag_upper(&litude_index);
699
700 Ok(MuodResult {
701 shape_outliers,
702 magnitude_outliers,
703 amplitude_outliers,
704 shape_index,
705 magnitude_index,
706 amplitude_index,
707 })
708}
709
710#[derive(Debug, Clone, Copy, PartialEq, Eq)]
721#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
722#[non_exhaustive]
723pub enum SeqTransform {
724 T0,
726 T1,
728 T2,
730 D1,
732 D2,
734}
735
736#[non_exhaustive]
742#[derive(Debug, Clone, Copy, PartialEq)]
743pub struct SeqTransformConfig {
744 pub depth_method: DepthMethod,
746 pub emp_factor: f64,
748}
749
750impl Default for SeqTransformConfig {
751 fn default() -> Self {
752 Self {
753 depth_method: DepthMethod::ModifiedBand,
754 emp_factor: 1.5,
755 }
756 }
757}
758
759#[derive(Debug, Clone, PartialEq)]
761#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
762#[non_exhaustive]
763pub struct SeqTransformOutliers {
764 pub per_transform_outliers: Vec<(SeqTransform, Vec<usize>)>,
766 pub union_outliers: Vec<usize>,
768}
769
770fn seq_transform_apply(current: &FdMatrix, t: SeqTransform) -> Result<FdMatrix, FdarError> {
772 let (n, m) = current.shape();
773 match t {
774 SeqTransform::T0 => Ok(current.clone()),
775 SeqTransform::T1 => {
776 let mut cm = vec![0.0; n * m];
777 for i in 0..n {
778 let mut mean = 0.0;
779 for j in 0..m {
780 mean += current[(i, j)];
781 }
782 mean /= m as f64;
783 for j in 0..m {
784 cm[i + j * n] = current[(i, j)] - mean;
785 }
786 }
787 FdMatrix::from_column_major(cm, n, m)
788 }
789 SeqTransform::T2 => {
790 let mut cm = vec![0.0; n * m];
791 for i in 0..n {
792 let mut norm = 0.0;
793 for j in 0..m {
794 norm += current[(i, j)].powi(2);
795 }
796 let norm = norm.sqrt();
797 if norm < 1e-15 {
798 return Err(FdarError::ComputationFailed {
799 operation: "T2 normalization",
800 detail: format!("zero-norm curve at row {i}"),
801 });
802 }
803 for j in 0..m {
804 cm[i + j * n] = current[(i, j)] / norm;
805 }
806 }
807 FdMatrix::from_column_major(cm, n, m)
808 }
809 SeqTransform::D1 | SeqTransform::D2 => {
810 if m < 2 {
811 return Err(FdarError::InvalidDimension {
812 parameter: "data",
813 expected: "at least 2 columns for lag-1 differencing".to_string(),
814 actual: format!("{m} columns"),
815 });
816 }
817 let m2 = m - 1;
818 let mut cm = vec![0.0; n * m2];
819 for i in 0..n {
820 for k in 0..m2 {
821 cm[i + k * n] = current[(i, k + 1)] - current[(i, k)];
822 }
823 }
824 FdMatrix::from_column_major(cm, n, m2)
825 }
826 }
827}
828
829#[must_use = "outlier detection results should not be discarded"]
841pub fn sequential_transform_outliers(
842 data: &FdMatrix,
843 sequence: &[SeqTransform],
844 config: SeqTransformConfig,
845) -> Result<SeqTransformOutliers, FdarError> {
846 let n = data.nrows();
847 if n < 2 {
848 return Err(FdarError::InvalidDimension {
849 parameter: "data",
850 expected: "at least 2 curves".to_string(),
851 actual: format!("{n} rows"),
852 });
853 }
854
855 let mut current = data.clone();
856 let mut per_transform_outliers = Vec::with_capacity(sequence.len());
857 for &t in sequence {
858 current = seq_transform_apply(¤t, t)?;
859 let fbp = functional_boxplot(¤t, config.depth_method, config.emp_factor)?;
860 per_transform_outliers.push((t, fbp.outliers));
861 }
862
863 let mut union_outliers: Vec<usize> = per_transform_outliers
864 .iter()
865 .flat_map(|(_, v)| v.iter().copied())
866 .collect();
867 union_outliers.sort_unstable();
868 union_outliers.dedup();
869
870 Ok(SeqTransformOutliers {
871 per_transform_outliers,
872 union_outliers,
873 })
874}
875
876#[non_exhaustive]
880#[derive(Debug, Clone, Copy, PartialEq)]
881#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
882pub struct DepthgramConfig {
883 pub outliergram_factor: f64,
885 pub boxplot_factor: f64,
887}
888
889impl Default for DepthgramConfig {
890 fn default() -> Self {
891 Self {
892 outliergram_factor: 1.5,
893 boxplot_factor: 1.5,
894 }
895 }
896}
897
898#[derive(Debug, Clone, PartialEq)]
903#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
904#[non_exhaustive]
905pub struct DepthgramResult {
906 pub mbd_mei_d: Vec<f64>,
908 pub mei_mbd_d: Vec<f64>,
910 pub mbd_mei_t: Vec<f64>,
912 pub mei_mbd_t: Vec<f64>,
914 pub mbd_mei_t2: Vec<f64>,
916 pub mei_mbd_t2: Vec<f64>,
918 pub shape_outliers: Vec<usize>,
920 pub magnitude_outliers: Vec<usize>,
922 pub mbd: Vec<f64>,
924 pub mei: Vec<f64>,
926}
927
928#[must_use = "outlier detection results should not be discarded"]
943pub fn depthgram(data: &FdMatrix, config: DepthgramConfig) -> Result<DepthgramResult, FdarError> {
944 let (n, m) = data.shape();
945 if n < 2 || m == 0 {
946 return Err(FdarError::InvalidDimension {
947 parameter: "data",
948 expected: "at least 2 curves and 1 column".to_string(),
949 actual: format!("{n} rows, {m} columns"),
950 });
951 }
952
953 let mbd = modified_band_1d(data, data);
954 let mei = modified_epigraph_index_1d(data, data);
955
956 let mei_mat = FdMatrix::from_column_major(mei.clone(), n, 1)?;
958 let mbd_mat = FdMatrix::from_column_major(mbd.clone(), n, 1)?;
959 let mbd_mei = modified_band_1d(&mei_mat, &mei_mat);
960 let mei_mbd = modified_epigraph_index_1d(&mbd_mat, &mbd_mat);
961
962 let nf = n as f64;
964 let a2 = -2.0 / (nf * (nf - 1.0));
965 let a0 = a2;
966 let a1 = 2.0 * (nf + 1.0) / (nf - 1.0);
967 let dist: Vec<f64> = (0..n)
968 .map(|i| (a0 + a1 * mei[i] + a2 * nf * nf * mei[i] * mei[i]) - mbd[i])
969 .collect();
970 let (_, upper) = iqr_fence(&dist, config.outliergram_factor);
971 let shape_outliers: Vec<usize> = (0..n).filter(|&i| dist[i] > upper).collect();
972
973 let mbd_mat2 = FdMatrix::from_column_major(mbd.clone(), n, 1)?;
975 let fbp = functional_boxplot(&mbd_mat2, DepthMethod::ModifiedBand, config.boxplot_factor)?;
976 let magnitude_outliers = fbp.outliers;
977
978 Ok(DepthgramResult {
979 mbd_mei_d: mbd_mei.clone(),
980 mei_mbd_d: mei_mbd.clone(),
981 mbd_mei_t: mbd_mei.clone(),
982 mei_mbd_t: mei_mbd.clone(),
983 mbd_mei_t2: mbd_mei,
984 mei_mbd_t2: mei_mbd,
985 shape_outliers,
986 magnitude_outliers,
987 mbd,
988 mei,
989 })
990}
991
992#[cfg(test)]
993mod tests {
994 use super::*;
995 use std::f64::consts::PI;
996
997 fn generate_normal_fdata(n: usize, m: usize, seed: u64) -> FdMatrix {
999 let mut rng = StdRng::seed_from_u64(seed);
1000 let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
1001
1002 let mut data = FdMatrix::zeros(n, m);
1003 for i in 0..n {
1004 let phase: f64 = rng.gen::<f64>() * 0.2;
1005 let amp: f64 = 1.0 + rng.gen::<f64>() * 0.1;
1006 for j in 0..m {
1007 let noise: f64 = rng.sample::<f64, _>(StandardNormal) * 0.05;
1008 data[(i, j)] = amp * (2.0 * PI * t[j] + phase).sin() + noise;
1009 }
1010 }
1011 data
1012 }
1013
1014 fn generate_data_with_outlier(n: usize, m: usize, n_outliers: usize) -> FdMatrix {
1016 let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
1017
1018 let mut data = FdMatrix::zeros(n, m);
1019
1020 for i in 0..(n - n_outliers) {
1022 for j in 0..m {
1023 data[(i, j)] = (2.0 * PI * t[j]).sin();
1024 }
1025 }
1026
1027 for i in (n - n_outliers)..n {
1029 for j in 0..m {
1030 data[(i, j)] = (2.0 * PI * t[j]).sin() + 10.0;
1031 }
1032 }
1033
1034 data
1035 }
1036
1037 #[test]
1040 fn test_outliers_threshold_lrt_returns_positive() {
1041 let n = 20;
1042 let m = 30;
1043 let data = generate_normal_fdata(n, m, 42);
1044
1045 let threshold = outliers_threshold_lrt(&data, 50, 0.1, 0.1, 42, 0.95);
1046
1047 assert!(threshold > 0.0, "Threshold should be positive");
1048 }
1049
1050 #[test]
1051 fn test_outliers_threshold_lrt_deterministic() {
1052 let n = 15;
1053 let m = 25;
1054 let data = generate_normal_fdata(n, m, 42);
1055
1056 let t1 = outliers_threshold_lrt(&data, 30, 0.1, 0.1, 123, 0.95);
1057 let t2 = outliers_threshold_lrt(&data, 30, 0.1, 0.1, 123, 0.95);
1058
1059 assert!(
1060 (t1 - t2).abs() < 1e-10,
1061 "Same seed should give same threshold"
1062 );
1063 }
1064
1065 #[test]
1066 fn test_outliers_threshold_lrt_percentile_effect() {
1067 let n = 20;
1068 let m = 30;
1069 let data = generate_normal_fdata(n, m, 42);
1070
1071 let t_low = outliers_threshold_lrt(&data, 50, 0.1, 0.1, 42, 0.50);
1072 let t_high = outliers_threshold_lrt(&data, 50, 0.1, 0.1, 42, 0.99);
1073
1074 assert!(
1075 t_high >= t_low,
1076 "Higher percentile should give higher or equal threshold"
1077 );
1078 }
1079
1080 #[test]
1081 fn test_outliers_threshold_lrt_invalid_input() {
1082 let data = FdMatrix::zeros(2, 30);
1084 let threshold = outliers_threshold_lrt(&data, 50, 0.1, 0.1, 42, 0.95);
1085 assert!(threshold.abs() < 1e-10, "Should return 0 for n < 3");
1086
1087 let data = FdMatrix::zeros(10, 0);
1089 let threshold = outliers_threshold_lrt(&data, 50, 0.1, 0.1, 42, 0.95);
1090 assert!(threshold.abs() < 1e-10);
1091 }
1092
1093 #[test]
1096 fn test_detect_outliers_lrt_finds_obvious_outlier() {
1097 let n = 20;
1098 let m = 30;
1099 let data = generate_data_with_outlier(n, m, 1);
1100
1101 let outliers = detect_outliers_lrt(&data, 3.0, 0.1);
1103
1104 assert_eq!(outliers.len(), n);
1105
1106 assert!(outliers[n - 1], "Obvious outlier should be detected");
1108
1109 let n_detected: usize = outliers.iter().filter(|&&x| x).count();
1111 assert!(n_detected <= 3, "Should not detect too many outliers");
1112 }
1113
1114 #[test]
1115 fn test_detect_outliers_lrt_homogeneous_data() {
1116 let n = 20;
1117 let m = 30;
1118 let data = generate_normal_fdata(n, m, 42);
1119
1120 let outliers = detect_outliers_lrt(&data, 100.0, 0.1);
1122
1123 let n_detected: usize = outliers.iter().filter(|&&x| x).count();
1124 assert_eq!(
1125 n_detected, 0,
1126 "Very high threshold should detect no outliers"
1127 );
1128 }
1129
1130 #[test]
1131 fn test_detect_outliers_lrt_threshold_effect() {
1132 let n = 20;
1133 let m = 30;
1134 let data = generate_data_with_outlier(n, m, 3);
1135
1136 let low_thresh = detect_outliers_lrt(&data, 2.0, 0.1);
1137 let high_thresh = detect_outliers_lrt(&data, 10.0, 0.1);
1138
1139 let n_low: usize = low_thresh.iter().filter(|&&x| x).count();
1140 let n_high: usize = high_thresh.iter().filter(|&&x| x).count();
1141
1142 assert!(
1143 n_low >= n_high,
1144 "Lower threshold should detect more or equal outliers"
1145 );
1146 }
1147
1148 #[test]
1149 fn test_detect_outliers_lrt_invalid_input() {
1150 let data = FdMatrix::zeros(2, 30);
1152 let outliers = detect_outliers_lrt(&data, 3.0, 0.1);
1153 assert_eq!(outliers.len(), 2);
1154 assert!(
1155 outliers.iter().all(|&x| !x),
1156 "Should return all false for n < 3"
1157 );
1158 }
1159
1160 #[test]
1161 fn test_identical_data_outliers() {
1162 let n = 10;
1163 let m = 20;
1164 let data = FdMatrix::from_column_major(vec![1.0; n * m], n, m).unwrap();
1165 let flags = detect_outliers_lrt(&data, 1.0, 0.15);
1166 assert_eq!(flags.len(), n);
1167 for &f in &flags {
1169 assert!(!f);
1170 }
1171 }
1172
1173 #[test]
1174 fn test_n3_minimal_outliers() {
1175 let n = 3;
1177 let m = 10;
1178 let mut data_vec = vec![0.0; n * m];
1179 for j in 0..m {
1181 data_vec[j * n] = 0.0;
1182 data_vec[1 + j * n] = 0.1;
1183 data_vec[2 + j * n] = 100.0;
1184 }
1185 let data = FdMatrix::from_column_major(data_vec, n, m).unwrap();
1186 let flags = detect_outliers_lrt(&data, 0.5, 0.15);
1187 assert_eq!(flags.len(), n);
1188 }
1189
1190 #[test]
1193 fn test_with_dist_returns_sorted_distribution() {
1194 let data = generate_normal_fdata(20, 30, 42);
1195 let nb = 50;
1196 let (threshold, dist) = outliers_threshold_lrt_with_dist(&data, nb, 0.1, 0.1, 42, 0.95);
1197
1198 assert_eq!(dist.len(), nb, "Distribution length should equal nb");
1199 for w in dist.windows(2) {
1200 assert!(w[0] <= w[1], "Distribution should be sorted");
1201 }
1202 let idx = ((nb as f64 * 0.95) as usize).min(nb - 1);
1203 assert!(
1204 (threshold - dist[idx]).abs() < 1e-10,
1205 "Threshold should match distribution at percentile index"
1206 );
1207 }
1208
1209 #[test]
1210 fn test_with_dist_matches_scalar() {
1211 let data = generate_normal_fdata(15, 25, 99);
1212 let scalar = outliers_threshold_lrt(&data, 40, 0.1, 0.1, 123, 0.95);
1213 let (with_dist, _) = outliers_threshold_lrt_with_dist(&data, 40, 0.1, 0.1, 123, 0.95);
1214 assert!(
1215 (scalar - with_dist).abs() < 1e-10,
1216 "Scalar version should match with_dist version"
1217 );
1218 }
1219
1220 #[test]
1221 fn test_bootstrap_dist_enables_pvalue() {
1222 let n = 20;
1223 let m = 30;
1224 let data = generate_data_with_outlier(n, m, 1);
1225 let trim = 0.1;
1226
1227 let (_, dist) = outliers_threshold_lrt_with_dist(&data, 200, 0.1, trim, 42, 0.99);
1228 let nb = dist.len();
1229
1230 let n_keep = ((1.0 - trim) * n as f64).ceil() as usize;
1232 let state = SortedReferenceState::from_reference(&data);
1233 let streaming_fm = StreamingFraimanMuniz::new(state, true);
1234 let depths = streaming_fm.depth_batch(&data);
1235 let (tmean, tvar) = compute_trimmed_stats(&data, &depths, n_keep);
1236
1237 let d_outlier = normalized_distance(&data, n - 1, &tmean, &tvar);
1239 let p_outlier =
1240 (dist.iter().filter(|&&v| v >= d_outlier).count() as f64 + 1.0) / (nb as f64 + 1.0);
1241
1242 let d_normal = normalized_distance(&data, 0, &tmean, &tvar);
1244 let p_normal =
1245 (dist.iter().filter(|&&v| v >= d_normal).count() as f64 + 1.0) / (nb as f64 + 1.0);
1246
1247 assert!(
1248 p_outlier < 0.05,
1249 "Outlier should have small p-value, got {p_outlier}"
1250 );
1251 assert!(
1252 p_normal > 0.05,
1253 "Normal curve should have large p-value, got {p_normal}"
1254 );
1255 }
1256
1257 #[test]
1258 fn test_with_dist_invalid_input() {
1259 let data = FdMatrix::zeros(2, 30);
1260 let (threshold, dist) = outliers_threshold_lrt_with_dist(&data, 50, 0.1, 0.1, 42, 0.95);
1261 assert!(threshold.abs() < 1e-10);
1262 assert!(dist.is_empty(), "Should return empty dist for n < 3");
1263 }
1264
1265 #[test]
1266 fn test_all_false_high_threshold() {
1267 let n = 10;
1268 let m = 20;
1269 let data_vec: Vec<f64> = (0..n * m).map(|i| (i as f64 * 0.1).sin()).collect();
1270 let data = FdMatrix::from_column_major(data_vec, n, m).unwrap();
1271 let flags = detect_outliers_lrt(&data, 1e10, 0.15);
1273 for &f in &flags {
1274 assert!(!f, "High threshold should produce no outliers");
1275 }
1276 }
1277
1278 #[test]
1281 fn test_trim_zero_no_trimming() {
1282 let data = generate_normal_fdata(10, 20, 42);
1284 let threshold = outliers_threshold_lrt(&data, 30, 0.1, 0.0, 42, 0.95);
1285 assert!(threshold > 0.0);
1286 let flags = detect_outliers_lrt(&data, threshold, 0.0);
1287 assert_eq!(flags.len(), 10);
1288 }
1289
1290 #[test]
1291 fn test_trim_near_one_heavy_trimming() {
1292 let data = generate_normal_fdata(10, 20, 42);
1294 let threshold = outliers_threshold_lrt(&data, 30, 0.1, 0.9, 42, 0.95);
1295 assert!(threshold >= 0.0);
1296 let flags = detect_outliers_lrt(&data, threshold, 0.9);
1297 assert_eq!(flags.len(), 10);
1298 }
1299
1300 #[test]
1301 fn test_trim_one_clamps_to_one() {
1302 let data = generate_normal_fdata(10, 20, 42);
1304 let threshold = outliers_threshold_lrt(&data, 30, 0.1, 1.0, 42, 0.95);
1305 assert!(threshold >= 0.0);
1306 let flags = detect_outliers_lrt(&data, threshold, 1.0);
1307 assert_eq!(flags.len(), 10);
1308 }
1309
1310 #[test]
1311 fn test_trim_negative_clamps_to_n() {
1312 let data = generate_normal_fdata(10, 20, 42);
1314 let threshold = outliers_threshold_lrt(&data, 30, 0.1, -0.5, 42, 0.95);
1315 assert!(threshold > 0.0);
1316 let flags = detect_outliers_lrt(&data, threshold, -0.5);
1317 assert_eq!(flags.len(), 10);
1318 }
1319
1320 #[test]
1323 fn test_smo_zero_no_noise() {
1324 let data = generate_normal_fdata(10, 20, 42);
1326 let (threshold, dist) = outliers_threshold_lrt_with_dist(&data, 30, 0.0, 0.1, 42, 0.95);
1327 assert!(threshold > 0.0);
1328 assert_eq!(dist.len(), 30);
1329 }
1330
1331 #[test]
1332 fn test_nb_zero_empty_bootstrap() {
1333 let data = generate_normal_fdata(10, 20, 42);
1334 let (threshold, dist) = outliers_threshold_lrt_with_dist(&data, 0, 0.1, 0.1, 42, 0.95);
1335 assert!(threshold.abs() < 1e-10);
1336 assert!(dist.is_empty());
1337 }
1338
1339 #[test]
1340 fn test_nb_one_single_bootstrap() {
1341 let data = generate_normal_fdata(10, 20, 42);
1342 let (threshold, dist) = outliers_threshold_lrt_with_dist(&data, 1, 0.1, 0.1, 42, 0.95);
1343 assert_eq!(dist.len(), 1);
1344 assert!((threshold - dist[0]).abs() < 1e-10);
1346 }
1347
1348 #[test]
1349 fn test_percentile_zero_returns_minimum() {
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_zero = outliers_threshold_lrt(&data, nb, 0.1, 0.1, 42, 0.0);
1354 assert!(
1355 (t_zero - dist[0]).abs() < 1e-10,
1356 "percentile=0 should return the minimum of the distribution"
1357 );
1358 }
1359
1360 #[test]
1361 fn test_percentile_one_returns_maximum() {
1362 let data = generate_normal_fdata(15, 20, 42);
1363 let nb = 50;
1364 let (_, dist) = outliers_threshold_lrt_with_dist(&data, nb, 0.1, 0.1, 42, 0.95);
1365 let t_one = outliers_threshold_lrt(&data, nb, 0.1, 0.1, 42, 1.0);
1366 assert!(
1367 (t_one - *dist.last().unwrap()).abs() < 1e-10,
1368 "percentile=1 should return the maximum of the distribution"
1369 );
1370 }
1371
1372 #[test]
1375 fn test_distribution_values_non_negative() {
1376 let data = generate_normal_fdata(15, 20, 42);
1377 let (_, dist) = outliers_threshold_lrt_with_dist(&data, 50, 0.1, 0.1, 42, 0.95);
1378 for &v in &dist {
1379 assert!(v >= 0.0, "Max-distances must be non-negative, got {v}");
1380 }
1381 }
1382
1383 #[test]
1386 fn test_detect_m_zero_returns_all_false() {
1387 let data = FdMatrix::zeros(10, 0);
1388 let flags = detect_outliers_lrt(&data, 3.0, 0.1);
1389 assert_eq!(flags.len(), 10);
1390 assert!(flags.iter().all(|&f| !f));
1391 }
1392
1393 #[test]
1394 fn test_detect_multiple_outliers() {
1395 let data = generate_data_with_outlier(20, 30, 3);
1396 let flags = detect_outliers_lrt(&data, 3.0, 0.1);
1397 let outlier_count = flags[17..20].iter().filter(|&&x| x).count();
1399 assert!(
1400 outlier_count >= 2,
1401 "At least 2 of 3 outliers should be detected, got {outlier_count}"
1402 );
1403 }
1404
1405 #[test]
1408 fn test_end_to_end_threshold_then_detect() {
1409 let data = generate_data_with_outlier(20, 30, 2);
1410 let threshold = outliers_threshold_lrt(&data, 100, 0.1, 0.1, 42, 0.99);
1411 let flags = detect_outliers_lrt(&data, threshold, 0.1);
1412
1413 assert!(
1415 flags[18] || flags[19],
1416 "At least one outlier should be detected in end-to-end flow"
1417 );
1418 let false_positives = flags[..18].iter().filter(|&&x| x).count();
1420 assert!(
1421 false_positives <= 2,
1422 "False positive count should be low, got {false_positives}"
1423 );
1424 }
1425
1426 #[test]
1427 fn test_end_to_end_with_dist_pvalues_all_curves() {
1428 let n = 25;
1430 let m = 30;
1431 let data = generate_data_with_outlier(n, m, 2);
1432 let trim = 0.1;
1433
1434 let (_, dist) = outliers_threshold_lrt_with_dist(&data, 200, 0.1, trim, 42, 0.99);
1435 let nb = dist.len();
1436
1437 let n_keep = ((1.0 - trim) * n as f64).ceil().max(1.0) as usize;
1438 let n_keep = n_keep.min(n);
1439 let state = SortedReferenceState::from_reference(&data);
1440 let streaming_fm = StreamingFraimanMuniz::new(state, true);
1441 let depths = streaming_fm.depth_batch(&data);
1442 let (tmean, tvar) = compute_trimmed_stats(&data, &depths, n_keep);
1443
1444 let pvalues: Vec<f64> = (0..n)
1446 .map(|i| {
1447 let d = normalized_distance(&data, i, &tmean, &tvar);
1448 (dist.iter().filter(|&&v| v >= d).count() as f64 + 1.0) / (nb as f64 + 1.0)
1449 })
1450 .collect();
1451
1452 let normal_small_p = pvalues[..23].iter().filter(|&&p| p < 0.01).count();
1454 assert_eq!(
1455 normal_small_p, 0,
1456 "Normal curves should not have tiny p-values"
1457 );
1458
1459 for &i in &[23, 24] {
1461 assert!(
1462 pvalues[i] < 0.05,
1463 "Outlier curve {i} should have small p-value, got {}",
1464 pvalues[i]
1465 );
1466 }
1467 }
1468
1469 fn outliergram_test_data() -> FdMatrix {
1472 let n = 20;
1474 let m = 30;
1475 let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
1476 let mut vals = vec![0.0; n * m];
1477 for i in 0..n {
1478 for (j, &tj) in t.iter().enumerate() {
1479 let base = tj.sin();
1480 vals[i + j * n] = if i < 18 {
1481 base + 0.1 * (i as f64 * 0.5).sin()
1482 } else {
1483 base + 2.0 * (if i == 18 { 1.0 } else { -1.0 })
1485 };
1486 }
1487 }
1488 FdMatrix::from_column_major(vals, n, m).unwrap()
1489 }
1490
1491 #[test]
1492 fn outliergram_runs() {
1493 let data = outliergram_test_data();
1494 let result = outliergram(&data, 1.5).unwrap();
1495 assert_eq!(result.mei.len(), 20);
1496 assert_eq!(result.mbd.len(), 20);
1497 assert_eq!(result.outlier_flags.len(), 20);
1498 let central_mbd: f64 = result.mbd[..18].iter().sum::<f64>() / 18.0;
1500 assert!(result.mbd[18] < central_mbd || result.mbd[19] < central_mbd);
1501 }
1502
1503 #[test]
1504 fn outliergram_parabola_coefficients() {
1505 let data = outliergram_test_data();
1506 let result = outliergram(&data, 1.5).unwrap();
1507 assert!(result.a0.is_finite());
1510 assert!(result.a1.is_finite());
1511 assert!(result.a2.is_finite());
1512 }
1513
1514 #[test]
1515 fn magnitude_shape_dimensions() {
1516 let data = outliergram_test_data();
1517 let result = magnitude_shape_outlyingness(&data).unwrap();
1518 assert_eq!(result.magnitude.len(), 20);
1519 assert_eq!(result.shape.len(), 20);
1520 assert!(result.magnitude.iter().all(|&v| v >= 0.0));
1522 assert!(result.shape.iter().all(|&v| v >= 0.0));
1523 }
1524
1525 #[test]
1526 fn magnitude_outliers_have_high_magnitude() {
1527 let data = outliergram_test_data();
1528 let result = magnitude_shape_outlyingness(&data).unwrap();
1529 let central_mag: f64 = result.magnitude[..18].iter().sum::<f64>() / 18.0;
1530 assert!(result.magnitude[18] > central_mag || result.magnitude[19] > central_mag);
1532 }
1533
1534 #[test]
1535 fn outliergram_too_few_curves() {
1536 let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0], 2, 2).unwrap();
1537 assert!(outliergram(&data, 1.5).is_err());
1538 }
1539
1540 fn outlier_sample(n: usize, m: usize, outlier_idx: usize, kind: &str) -> FdMatrix {
1547 let inlier_shape = |i: usize, x: f64| -> f64 {
1550 (x * PI).sin() + 0.1 * (x * 4.0 * PI + 0.3 * i as f64).sin()
1551 };
1552 let mut cm = vec![0.0; n * m];
1553 for i in 0..n {
1554 for t in 0..m {
1555 let x = t as f64 / (m as f64 - 1.0);
1556 let val = if i == outlier_idx {
1557 match kind {
1558 "magnitude" => inlier_shape(i, x) + 10.0,
1560 "amplitude" => 5.0 * inlier_shape(i, x),
1561 "shape" => (x * PI).sin() + 0.4 * (x * 12.0 * PI).sin(),
1565 "constant" => 0.5,
1566 _ => inlier_shape(i, x),
1567 }
1568 } else {
1569 inlier_shape(i, x)
1570 };
1571 cm[i + t * n] = val;
1572 }
1573 }
1574 FdMatrix::from_column_major(cm, n, m).unwrap()
1575 }
1576
1577 #[test]
1578 fn tvdmss_flags_magnitude_outlier() {
1579 let idx = 4usize;
1580 let data = outlier_sample(12, 40, idx, "magnitude");
1581 let res = tvdmss(&data, TvdMssConfig::default()).unwrap();
1582 assert_eq!(res.tvd.len(), 12);
1583 assert_eq!(res.mss.len(), 12);
1584 assert!(
1585 res.magnitude_outliers.contains(&idx),
1586 "magnitude outlier {idx} not flagged: {:?}",
1587 res.magnitude_outliers
1588 );
1589 }
1590
1591 #[test]
1592 fn tvdmss_flags_shape_outlier() {
1593 let idx = 7usize;
1594 let data = outlier_sample(12, 60, idx, "shape");
1595 let res = tvdmss(&data, TvdMssConfig::default()).unwrap();
1596 assert!(
1597 res.shape_outliers.contains(&idx),
1598 "shape outlier {idx} not flagged: {:?}",
1599 res.shape_outliers
1600 );
1601 }
1602
1603 #[test]
1604 fn tvdmss_rejects_empty_and_too_few() {
1605 let empty = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
1606 assert!(matches!(
1607 tvdmss(&empty, TvdMssConfig::default()),
1608 Err(FdarError::InvalidDimension { .. })
1609 ));
1610 let two = outlier_sample(2, 8, 0, "none");
1611 assert!(matches!(
1612 tvdmss(&two, TvdMssConfig::default()),
1613 Err(FdarError::InvalidDimension { .. })
1614 ));
1615 }
1616
1617 #[test]
1618 fn muod_flags_magnitude_amplitude_shape() {
1619 let mag = outlier_sample(12, 40, 3, "magnitude");
1620 let r = muod(&mag, MuodConfig::default()).unwrap();
1621 assert_eq!(r.shape_index.len(), 12);
1622 assert!(
1623 r.magnitude_outliers.contains(&3),
1624 "magnitude: {:?}",
1625 r.magnitude_outliers
1626 );
1627
1628 let amp = outlier_sample(12, 40, 5, "amplitude");
1629 let r = muod(&, MuodConfig::default()).unwrap();
1630 assert!(
1631 r.amplitude_outliers.contains(&5),
1632 "amplitude: {:?}",
1633 r.amplitude_outliers
1634 );
1635
1636 let shp = outlier_sample(12, 60, 8, "shape");
1637 let r = muod(&shp, MuodConfig::default()).unwrap();
1638 assert!(
1639 r.shape_outliers.contains(&8),
1640 "shape: {:?}",
1641 r.shape_outliers
1642 );
1643 }
1644
1645 #[test]
1646 fn muod_constant_curve_no_nan() {
1647 let data = outlier_sample(12, 40, 6, "constant");
1648 let r = muod(&data, MuodConfig::default()).unwrap();
1649 for v in r
1650 .shape_index
1651 .iter()
1652 .chain(&r.magnitude_index)
1653 .chain(&r.amplitude_index)
1654 {
1655 assert!(!v.is_nan(), "index produced NaN");
1656 }
1657 }
1658
1659 #[test]
1660 fn muod_rejects_bad_dims() {
1661 let two = outlier_sample(2, 8, 0, "none");
1662 assert!(matches!(
1663 muod(&two, MuodConfig::default()),
1664 Err(FdarError::InvalidDimension { .. })
1665 ));
1666 let one_col = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 3, 1).unwrap();
1667 assert!(matches!(
1668 muod(&one_col, MuodConfig::default()),
1669 Err(FdarError::InvalidDimension { .. })
1670 ));
1671 }
1672
1673 #[test]
1676 fn seq_transform_default_sequence_flags_outlier_and_union_is_flatten() {
1677 let idx = 4usize;
1678 let data = outlier_sample(12, 40, idx, "magnitude");
1679 let seq = [SeqTransform::T0, SeqTransform::T1, SeqTransform::D1];
1680 let res =
1681 sequential_transform_outliers(&data, &seq, SeqTransformConfig::default()).unwrap();
1682
1683 assert_eq!(res.per_transform_outliers.len(), 3);
1684 assert!(
1685 res.per_transform_outliers
1686 .iter()
1687 .any(|(_, v)| !v.is_empty()),
1688 "no transform flagged anything"
1689 );
1690 assert!(
1691 res.union_outliers.contains(&idx),
1692 "union {:?} missing outlier {idx}",
1693 res.union_outliers
1694 );
1695 let mut expected: Vec<usize> = res
1697 .per_transform_outliers
1698 .iter()
1699 .flat_map(|(_, v)| v.iter().copied())
1700 .collect();
1701 expected.sort_unstable();
1702 expected.dedup();
1703 assert_eq!(res.union_outliers, expected);
1704 }
1705
1706 #[test]
1707 fn seq_transform_error_paths() {
1708 let one_col = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 3, 1).unwrap();
1710 assert!(matches!(
1711 sequential_transform_outliers(
1712 &one_col,
1713 &[SeqTransform::D1],
1714 SeqTransformConfig::default()
1715 ),
1716 Err(FdarError::InvalidDimension { .. })
1717 ));
1718 let mut cm = vec![0.0; 3 * 4];
1720 for i in [0usize, 2] {
1721 for t in 0..4 {
1722 cm[i + t * 3] = 1.0 + i as f64 + t as f64;
1723 }
1724 }
1725 let zero_row = FdMatrix::from_column_major(cm, 3, 4).unwrap();
1727 assert!(matches!(
1728 sequential_transform_outliers(
1729 &zero_row,
1730 &[SeqTransform::T2],
1731 SeqTransformConfig::default()
1732 ),
1733 Err(FdarError::ComputationFailed { .. })
1734 ));
1735 let one_curve = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
1737 assert!(matches!(
1738 sequential_transform_outliers(
1739 &one_curve,
1740 &[SeqTransform::T0],
1741 SeqTransformConfig::default()
1742 ),
1743 Err(FdarError::InvalidDimension { .. })
1744 ));
1745 }
1746
1747 #[test]
1748 fn depthgram_flags_magnitude_and_shape() {
1749 let mag = outlier_sample(12, 40, 3, "magnitude");
1750 let r = depthgram(&mag, DepthgramConfig::default()).unwrap();
1751 assert_eq!(r.mbd.len(), 12);
1752 assert_eq!(r.mei.len(), 12);
1753 assert_eq!(r.mbd_mei_d.len(), 12);
1754 assert!(
1755 r.magnitude_outliers.contains(&3),
1756 "magnitude: {:?}",
1757 r.magnitude_outliers
1758 );
1759
1760 let shp = outlier_sample(14, 60, 9, "shape");
1761 let r = depthgram(&shp, DepthgramConfig::default()).unwrap();
1762 assert!(
1763 r.shape_outliers.contains(&9),
1764 "shape: {:?}",
1765 r.shape_outliers
1766 );
1767 }
1768
1769 #[test]
1770 fn depthgram_p1_representations_equivalent() {
1771 let data = outlier_sample(10, 30, 2, "magnitude");
1772 let r = depthgram(&data, DepthgramConfig::default()).unwrap();
1773 assert_eq!(r.mbd_mei_d, r.mbd_mei_t);
1774 assert_eq!(r.mbd_mei_d, r.mbd_mei_t2);
1775 assert_eq!(r.mei_mbd_d, r.mei_mbd_t);
1776 assert_eq!(r.mei_mbd_d, r.mei_mbd_t2);
1777 }
1778
1779 #[test]
1780 fn depthgram_rejects_bad_dims() {
1781 let one = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
1782 assert!(matches!(
1783 depthgram(&one, DepthgramConfig::default()),
1784 Err(FdarError::InvalidDimension { .. })
1785 ));
1786 let empty = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
1787 assert!(matches!(
1788 depthgram(&empty, DepthgramConfig::default()),
1789 Err(FdarError::InvalidDimension { .. })
1790 ));
1791 }
1792}