1use u_numflow::fourier::{fft, ifft, Complex};
52use u_numflow::special::inverse_normal_cdf;
53use u_numflow::stats::median;
54
55pub const MIN_OBSERVATIONS: usize = 12;
57
58const _: () = assert!(
62 MIN_OBSERVATIONS == 12,
63 "the batch_size requirement message spells this number"
64);
65
66const EXTENSION: usize = 5;
70
71#[derive(Debug, Clone, Copy, PartialEq)]
78#[non_exhaustive]
79pub enum SpectralResidualError {
80 OptionOutOfRange {
86 option: &'static str,
89 requirement: &'static str,
91 got: f64,
93 min: Option<f64>,
95 exclusive_min: Option<f64>,
97 max: Option<f64>,
99 exclusive_max: Option<f64>,
101 },
102 TooFewObservations {
104 needed: usize,
106 got: usize,
108 },
109 ValueNotFinite {
111 index: usize,
113 },
114}
115
116impl core::fmt::Display for SpectralResidualError {
117 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
118 match self {
119 SpectralResidualError::OptionOutOfRange {
120 option,
121 requirement,
122 ..
123 } => write!(f, "{option} must be {requirement}"),
124 SpectralResidualError::TooFewObservations { needed, got } => {
125 write!(f, "needs at least {needed} observations, got {got}")
126 }
127 SpectralResidualError::ValueNotFinite { index } => {
128 write!(f, "data[{index}] is not a finite number")
129 }
130 }
131 }
132}
133
134impl std::error::Error for SpectralResidualError {}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub(crate) enum CountOption {
143 AveragingWindow,
144 JudgementWindow,
145 BatchSize,
146}
147
148impl CountOption {
149 pub(crate) fn check(self, got: f64) -> Result<(), SpectralResidualError> {
151 let (option, requirement, min) = match self {
152 CountOption::AveragingWindow => ("averaging_window", ">= 1", 1),
153 CountOption::JudgementWindow => ("judgement_window", ">= 1", 1),
154 CountOption::BatchSize => ("batch_size", "unset, or at least 12", MIN_OBSERVATIONS),
155 };
156 if got < min as f64 {
157 Err(SpectralResidualError::below(
158 option,
159 requirement,
160 got,
161 min as f64,
162 ))
163 } else {
164 Ok(())
165 }
166 }
167}
168
169impl SpectralResidualError {
170 fn below(option: &'static str, requirement: &'static str, got: f64, min: f64) -> Self {
172 Self::OptionOutOfRange {
173 option,
174 requirement,
175 got,
176 min: Some(min),
177 exclusive_min: None,
178 max: None,
179 exclusive_max: None,
180 }
181 }
182
183 fn not_above(
185 option: &'static str,
186 requirement: &'static str,
187 got: f64,
188 exclusive_min: f64,
189 ) -> Self {
190 Self::OptionOutOfRange {
191 option,
192 requirement,
193 got,
194 min: None,
195 exclusive_min: Some(exclusive_min),
196 max: None,
197 exclusive_max: None,
198 }
199 }
200
201 fn not_between(
203 option: &'static str,
204 requirement: &'static str,
205 got: f64,
206 exclusive_min: f64,
207 exclusive_max: f64,
208 ) -> Self {
209 Self::OptionOutOfRange {
210 option,
211 requirement,
212 got,
213 min: None,
214 exclusive_min: Some(exclusive_min),
215 max: None,
216 exclusive_max: Some(exclusive_max),
217 }
218 }
219}
220
221#[derive(Debug, Clone, PartialEq)]
226pub struct SpectralResidual {
227 pub averaging_window: usize,
230 pub judgement_window: usize,
233 pub threshold: f64,
235 pub min_zscore: f64,
240 pub sensitivity: f64,
244 pub batch_size: Option<usize>,
249}
250
251impl Default for SpectralResidual {
252 fn default() -> Self {
253 Self::new()
254 }
255}
256
257impl SpectralResidual {
258 pub fn new() -> Self {
261 Self {
262 averaging_window: 3,
263 judgement_window: 40,
264 threshold: 3.0,
265 min_zscore: 1.5,
266 sensitivity: 70.0,
267 batch_size: None,
268 }
269 }
270
271 pub fn with_averaging_window(mut self, q: usize) -> Self {
273 self.averaging_window = q;
274 self
275 }
276
277 pub fn with_judgement_window(mut self, z: usize) -> Self {
279 self.judgement_window = z;
280 self
281 }
282
283 pub fn with_threshold(mut self, threshold: f64) -> Self {
285 self.threshold = threshold;
286 self
287 }
288
289 pub fn with_min_zscore(mut self, min_zscore: f64) -> Self {
291 self.min_zscore = min_zscore;
292 self
293 }
294
295 pub fn with_sensitivity(mut self, sensitivity: f64) -> Self {
297 self.sensitivity = sensitivity;
298 self
299 }
300
301 pub fn with_batch_size(mut self, batch_size: Option<usize>) -> Self {
303 self.batch_size = batch_size;
304 self
305 }
306
307 fn check_options(&self) -> Result<(), SpectralResidualError> {
309 use SpectralResidualError as E;
310 CountOption::AveragingWindow.check(self.averaging_window as f64)?;
311 CountOption::JudgementWindow.check(self.judgement_window as f64)?;
312 if !self.threshold.is_finite() || self.threshold <= 0.0 {
313 return Err(E::not_above(
314 "threshold",
315 "a finite number > 0",
316 self.threshold,
317 0.0,
318 ));
319 }
320 if !self.min_zscore.is_finite() || self.min_zscore < 0.0 {
321 return Err(E::below(
322 "min_zscore",
323 "a finite number >= 0",
324 self.min_zscore,
325 0.0,
326 ));
327 }
328 if !self.sensitivity.is_finite() || self.sensitivity <= 0.0 || self.sensitivity >= 100.0 {
329 return Err(E::not_between(
330 "sensitivity",
331 "a finite number strictly between 0 and 100",
332 self.sensitivity,
333 0.0,
334 100.0,
335 ));
336 }
337 if let Some(b) = self.batch_size {
338 CountOption::BatchSize.check(b as f64)?;
339 }
340 Ok(())
341 }
342
343 pub fn analyze(&self, series: &[f64]) -> Result<Vec<SrPoint>, SpectralResidualError> {
381 self.check_options()?;
382 if series.len() < MIN_OBSERVATIONS {
383 return Err(SpectralResidualError::TooFewObservations {
384 needed: MIN_OBSERVATIONS,
385 got: series.len(),
386 });
387 }
388 if let Some(index) = series.iter().position(|v| !v.is_finite()) {
389 return Err(SpectralResidualError::ValueNotFinite { index });
390 }
391 let n = series.len();
392 let batch = self.batch_size.unwrap_or(n).min(n);
393 let mut points = Vec::with_capacity(n);
394 let mut start = 0;
395 while start < n {
396 let end = if n - start < 2 * batch {
398 n
399 } else {
400 start + batch
401 };
402 points.extend(self.analyze_batch(&series[start..end], start));
403 start = end;
404 }
405 Ok(points)
406 }
407
408 fn analyze_batch(&self, data: &[f64], offset: usize) -> Vec<SrPoint> {
409 let n = data.len();
410 let saliency = saliency_map(data, self.averaging_window);
411
412 let scores: Vec<f64> = (0..n)
415 .map(|i| {
416 let from = i.saturating_sub(self.judgement_window - 1);
417 let avg = saliency[from..=i].iter().sum::<f64>() / (i + 1 - from) as f64;
418 let avg = avg.max(1e-8);
419 ((saliency[i] - avg) / avg).max(0.0)
420 })
421 .collect();
422
423 let flagged: Vec<bool> = (0..n)
427 .map(|i| {
428 if scores[i] <= self.threshold {
429 return false;
430 }
431 if self.min_zscore == 0.0 || i == 0 {
432 return true;
433 }
434 let from = i.saturating_sub(self.judgement_window);
435 let window = &data[from..i];
436 let mean = window.iter().sum::<f64>() / window.len() as f64;
437 let std = (window.iter().map(|v| (v - mean).powi(2)).sum::<f64>()
438 / window.len() as f64)
439 .sqrt();
440 if std > 0.0 {
441 (data[i] - mean).abs() / std >= self.min_zscore
442 } else {
443 data[i] != mean
444 }
445 })
446 .collect();
447
448 let expected = expected_values(data, &flagged);
449 let residuals: Vec<f64> = (0..n)
450 .filter(|&i| !flagged[i])
451 .map(|i| (data[i] - expected[i]).abs())
452 .collect();
453 let spread = median(&residuals).unwrap_or(0.0) * 1.4826;
457 let z = inverse_normal_cdf(0.5 + self.sensitivity / 200.0);
458 let margin = z * spread;
459
460 (0..n)
461 .map(|i| SrPoint {
462 index: offset + i,
463 value: data[i],
464 saliency: saliency[i],
465 score: scores[i],
466 expected: expected[i],
467 lower: expected[i] - margin,
468 upper: expected[i] + margin,
469 is_anomaly: flagged[i],
470 near_edge: i < EXTENSION || i + EXTENSION >= n,
471 })
472 .collect()
473 }
474}
475
476#[derive(Debug, Clone, PartialEq)]
478pub struct SrPoint {
479 pub index: usize,
481 pub value: f64,
483 pub saliency: f64,
485 pub score: f64,
487 pub expected: f64,
489 pub lower: f64,
491 pub upper: f64,
493 pub is_anomaly: bool,
495 pub near_edge: bool,
511}
512
513fn saliency_map(data: &[f64], q: usize) -> Vec<f64> {
518 let n = data.len();
519 let mut extended = data.to_vec();
520 let m = EXTENSION.min(n - 1);
521 let last = data[n - 1];
522 let slope = (1..=m)
523 .map(|i| (last - data[n - 1 - i]) / i as f64)
524 .sum::<f64>()
525 / m as f64;
526 let predicted = data[n - m] + slope * m as f64;
529 extended.extend(std::iter::repeat_n(predicted, EXTENSION));
530
531 let spectrum = fft(&extended
532 .iter()
533 .map(|&x| Complex::real(x))
534 .collect::<Vec<_>>());
535 let len = spectrum.len();
536 let log_amp: Vec<f64> = spectrum.iter().map(|z| (z.norm() + 1e-8).ln()).collect();
537 let mut avg = vec![0.0; len];
539 let mut cum = 0.0;
540 let mut cums = Vec::with_capacity(len + 1);
541 cums.push(0.0);
542 for &v in &log_amp {
543 cum += v;
544 cums.push(cum);
545 }
546 for i in 0..len {
547 let from = (i + 1).saturating_sub(q);
548 avg[i] = (cums[i + 1] - cums[from]) / (i + 1 - from) as f64;
549 }
550 let residual_spectrum: Vec<Complex> = spectrum
551 .iter()
552 .enumerate()
553 .map(|(i, z)| {
554 let r = (log_amp[i] - avg[i]).exp();
555 let phase = z.arg();
556 Complex::new(r * phase.cos(), r * phase.sin())
557 })
558 .collect();
559 ifft(&residual_spectrum)
560 .into_iter()
561 .take(n)
562 .map(|z| z.norm())
563 .collect()
564}
565
566fn expected_values(data: &[f64], flagged: &[bool]) -> Vec<f64> {
571 let n = data.len();
572 let mut cleaned = data.to_vec();
573 for i in 0..n {
574 if !flagged[i] {
575 continue;
576 }
577 let left = (0..i).rev().find(|&j| !flagged[j]).map(|j| data[j]);
578 let right = (i + 1..n).find(|&j| !flagged[j]).map(|j| data[j]);
579 cleaned[i] = match (left, right) {
580 (Some(a), Some(b)) => (a + b) / 2.0,
581 (Some(a), None) | (None, Some(a)) => a,
582 (None, None) => data[i],
583 };
584 }
585 let mut spectrum = fft(&cleaned
586 .iter()
587 .map(|&x| Complex::real(x))
588 .collect::<Vec<_>>());
589 let lo = n * 3 / 8;
590 let hi = n * 5 / 8;
591 for (i, z) in spectrum.iter_mut().enumerate() {
592 if i > lo && i < hi {
593 *z = Complex::default();
594 }
595 }
596 ifft(&spectrum).into_iter().map(|z| z.re).collect()
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602
603 fn noise(seed: u64, n: usize) -> Vec<f64> {
604 let mut state = seed;
605 (0..n)
606 .map(|_| {
607 state = state
608 .wrapping_mul(6364136223846793005)
609 .wrapping_add(1442695040888963407);
610 ((state >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
611 })
612 .collect()
613 }
614
615 fn check_shape(points: &[SrPoint], n: usize) {
616 assert_eq!(points.len(), n);
617 for (i, p) in points.iter().enumerate() {
618 assert_eq!(p.index, i);
619 assert!(p.saliency >= 0.0 && p.saliency.is_finite(), "{p:?}");
620 assert!(p.score >= 0.0 && p.score.is_finite(), "{p:?}");
621 assert!(
622 p.expected.is_finite() && p.lower <= p.expected && p.expected <= p.upper,
623 "{p:?}"
624 );
625 }
626 }
627
628 #[test]
629 fn a_spike_on_a_sine_is_the_anomaly() {
630 let mut series: Vec<f64> = noise(3, 120)
631 .iter()
632 .enumerate()
633 .map(|(t, e)| (t as f64 * 0.25).sin() + 0.1 * e)
634 .collect();
635 series[80] += 5.0;
636 let points = SpectralResidual::new().analyze(&series).unwrap();
637 check_shape(&points, 120);
638 assert!(points[80].is_anomaly, "{:?}", points[80]);
639 let flagged: Vec<usize> = points
640 .iter()
641 .filter(|p| p.is_anomaly)
642 .map(|p| p.index)
643 .collect();
644 assert!(flagged.len() <= 3, "flagged {flagged:?}");
645 assert!(points[80].value > points[80].upper);
646 assert!(
648 (points[80].expected - (80.0f64 * 0.25).sin()).abs() < 0.6,
649 "{:?}",
650 points[80]
651 );
652 }
653
654 #[test]
655 fn a_step_is_flagged_where_it_happens() {
656 let series: Vec<f64> = (0..100).map(|t| if t < 60 { 1.0 } else { 6.0 }).collect();
657 let points = SpectralResidual::new().analyze(&series).unwrap();
658 check_shape(&points, 100);
659 let first = points.iter().position(|p| p.is_anomaly);
660 assert_eq!(
661 first,
662 Some(60),
663 "{:?}",
664 points
665 .iter()
666 .filter(|p| p.is_anomaly)
667 .map(|p| p.index)
668 .collect::<Vec<_>>()
669 );
670 }
671
672 #[test]
673 fn a_constant_and_plain_noise_raise_no_anomaly() {
674 let constant = vec![3.0; 50];
675 let points = SpectralResidual::new().analyze(&constant).unwrap();
676 check_shape(&points, 50);
677 assert!(points.iter().all(|p| !p.is_anomaly));
678 assert!(points.iter().all(|p| (p.expected - 3.0).abs() < 1e-9));
679
680 let series = noise(9, 200);
681 let points = SpectralResidual::new().analyze(&series).unwrap();
682 check_shape(&points, 200);
683 let flagged = points.iter().filter(|p| p.is_anomaly).count();
684 assert!(flagged <= 4, "{flagged} of 200 noise points flagged");
685 }
686
687 #[test]
688 fn scoring_is_deterministic_and_batching_keeps_the_shape() {
689 let mut series: Vec<f64> = noise(5, 300).iter().map(|e| 10.0 + e).collect();
690 series[150] = 20.0;
691 series[260] = -5.0;
692 let sr = SpectralResidual::new();
693 let a = sr.analyze(&series).unwrap();
694 let b = sr.analyze(&series).unwrap();
695 assert_eq!(a, b);
696 let batched = sr.with_batch_size(Some(100)).analyze(&series).unwrap();
697 check_shape(&batched, 300);
698 assert!(batched[150].is_anomaly && batched[260].is_anomaly);
699 assert_eq!(batched[200].index, 200);
702 }
703
704 #[test]
705 fn the_band_widens_with_sensitivity() {
706 let series: Vec<f64> = noise(1, 80).iter().map(|e| 5.0 + e).collect();
707 let narrow = SpectralResidual::new()
708 .with_sensitivity(50.0)
709 .analyze(&series)
710 .unwrap();
711 let wide = SpectralResidual::new()
712 .with_sensitivity(99.0)
713 .analyze(&series)
714 .unwrap();
715 for (a, b) in narrow.iter().zip(&wide) {
716 assert!(b.upper - b.lower > a.upper - a.lower);
717 assert_eq!(a.expected, b.expected);
718 }
719 }
720
721 #[test]
725 fn points_near_either_end_of_a_batch_are_marked() {
726 let series: Vec<f64> = (0..200).map(|i| (i % 7) as f64).collect();
727 let points = SpectralResidual::new().analyze(&series).expect("ok");
728 let marked: Vec<usize> = points
729 .iter()
730 .filter(|p| p.near_edge)
731 .map(|p| p.index)
732 .collect();
733 assert_eq!(marked, vec![0, 1, 2, 3, 4, 195, 196, 197, 198, 199]);
734
735 let batched = SpectralResidual::new()
737 .with_batch_size(Some(50))
738 .analyze(&series)
739 .expect("ok");
740 let marked: Vec<usize> = batched
741 .iter()
742 .filter(|p| p.near_edge)
743 .map(|p| p.index)
744 .collect();
745 assert!(marked.contains(&49) && marked.contains(&50), "{marked:?}");
748 assert!(!marked.contains(&75), "{marked:?}");
749 assert_eq!(
750 marked.len(),
751 4 * 10,
752 "four batches, ten marks each: {marked:?}"
753 );
754
755 let mut spiked: Vec<f64> = (0..60).map(|i| (i % 7) as f64).collect();
758 spiked[57] += 30.0;
759 let points = SpectralResidual::new().analyze(&spiked).expect("ok");
760 assert!(points[57].is_anomaly);
761 assert!(points[57].near_edge);
762 }
763
764 #[test]
767 fn a_step_can_flag_the_point_before_the_transition() {
768 let step: Vec<f64> = (0..120)
769 .map(|i| {
770 let jitter = ((i as f64 * 7.3891).sin() * 10000.0).fract().abs() * 0.2;
771 (if i < 60 { 1.0 } else { 6.0 }) + jitter
772 })
773 .collect();
774 let points = SpectralResidual::new().analyze(&step).expect("ok");
775 let flagged: Vec<usize> = points
776 .iter()
777 .filter(|p| p.is_anomaly && !p.near_edge)
778 .map(|p| p.index)
779 .collect();
780 assert!(!flagged.is_empty(), "the transition is detected");
781 assert!(
782 flagged.iter().all(|&i| (59..=61).contains(&i)),
783 "detections sit at the transition or its neighbour: {flagged:?}"
784 );
785 }
786
787 #[test]
790 fn a_refusal_names_the_condition_that_failed() {
791 let ok = vec![1.0; 20];
792 use SpectralResidualError as E;
793
794 assert_eq!(
795 SpectralResidual::new().analyze(&[1.0; 11]),
796 Err(SpectralResidualError::TooFewObservations {
797 needed: MIN_OBSERVATIONS,
798 got: 11
799 })
800 );
801
802 let mut series = vec![1.0; 20];
803 series[7] = f64::INFINITY;
804 assert_eq!(
805 SpectralResidual::new().analyze(&series),
806 Err(SpectralResidualError::ValueNotFinite { index: 7 })
807 );
808
809 assert_eq!(
813 SpectralResidual::new().with_threshold(0.0).analyze(&ok),
814 Err(E::not_above("threshold", "a finite number > 0", 0.0, 0.0))
815 );
816 let sensitivity = |got| {
817 Err(E::not_between(
818 "sensitivity",
819 "a finite number strictly between 0 and 100",
820 got,
821 0.0,
822 100.0,
823 ))
824 };
825 assert_eq!(
826 SpectralResidual::new().with_sensitivity(100.0).analyze(&ok),
827 sensitivity(100.0)
828 );
829 assert_eq!(
830 SpectralResidual::new().with_sensitivity(0.0).analyze(&ok),
831 sensitivity(0.0)
832 );
833 assert_eq!(
834 SpectralResidual::new()
835 .with_batch_size(Some(5))
836 .analyze(&ok),
837 Err(E::below("batch_size", "unset, or at least 12", 5.0, 12.0))
838 );
839 assert_eq!(
840 SpectralResidual::new()
841 .with_averaging_window(0)
842 .analyze(&ok),
843 Err(E::below("averaging_window", ">= 1", 0.0, 1.0))
844 );
845 assert_eq!(
846 SpectralResidual::new()
847 .with_judgement_window(0)
848 .analyze(&ok),
849 Err(E::below("judgement_window", ">= 1", 0.0, 1.0))
850 );
851 assert_eq!(
852 SpectralResidual::new().with_min_zscore(-1.0).analyze(&ok),
853 Err(E::below("min_zscore", "a finite number >= 0", -1.0, 0.0))
854 );
855 assert!(matches!(
857 SpectralResidual::new()
858 .with_threshold(f64::NAN)
859 .analyze(&ok),
860 Err(E::OptionOutOfRange {
861 option: "threshold",
862 min: None,
863 exclusive_min,
864 max: None,
865 exclusive_max: None,
866 got,
867 ..
868 }) if got.is_nan() && exclusive_min == Some(0.0)
869 ));
870
871 let message = SpectralResidual::new()
873 .with_threshold(0.0)
874 .analyze(&ok)
875 .expect_err("refused")
876 .to_string();
877 assert_eq!(message, "threshold must be a finite number > 0");
878 assert!(!message.contains("sensitivity"));
879
880 assert!(SpectralResidual::new()
882 .with_min_zscore(0.0)
883 .analyze(&ok)
884 .is_ok());
885 }
886}
887
888#[cfg(test)]
897mod numpy_reference {
898 use super::*;
899
900 const SERIES: [f64; 64] = [
901 0.061, 0.585, 0.52, 1.134, 1.216, 0.847, 0.806, 0.369, -0.109, -0.265, -0.526, -1.008,
902 -1.139, -0.838, -0.869, -0.672, -0.068, 0.575, 0.594, 0.806, 0.924, 0.716, 0.839, 0.411,
903 0.217, -0.158, -0.583, -1.146, -1.238, -0.975, 3.168, -0.527, 0.231, 0.418, 0.492, 1.129,
904 0.898, 1.212, 0.745, 0.17, -0.134, -0.308, -0.865, -0.867, -0.867, -0.913, -0.746, -0.503,
905 0.119, 0.423, 0.811, 0.793, 0.878, 1.089, 0.49, 0.568, 0.154, -0.484, -0.677, -1.11,
906 -0.991, -0.84, -0.504, -0.44,
907 ];
908 const SALIENCY: [f64; 64] = [
909 0.0369614318108603,
910 0.0796307697333925,
911 0.0271892659280146,
912 0.152493866186463,
913 0.166141948303089,
914 0.0816656918320778,
915 0.107174248120332,
916 0.0556332303417036,
917 0.0251300576938508,
918 0.00299138253005822,
919 0.00594392958654813,
920 0.106106695034459,
921 0.119126565861323,
922 0.0424769851751049,
923 0.0893964151003082,
924 0.0920927965046129,
925 0.0198418341809379,
926 0.0882438287553024,
927 0.0549823475501431,
928 0.067869463097445,
929 0.113994918150788,
930 0.105398733553807,
931 0.119888725456503,
932 0.0746552451898384,
933 0.109038966340197,
934 0.0173152365850345,
935 0.0229006048210958,
936 0.132971301432821,
937 0.205056469142638,
938 0.150938526823712,
939 0.830517907514111,
940 0.128639067122104,
941 0.0093039215691647,
942 0.0611436432409572,
943 0.0458141563355618,
944 0.143893509514701,
945 0.129454085999137,
946 0.179180669344178,
947 0.105280578308881,
948 0.0679545755367448,
949 0.0275280318011067,
950 0.0125531569333055,
951 0.0908993020495842,
952 0.0747621046819059,
953 0.0497022369555018,
954 0.0789105027673606,
955 0.0665323274213843,
956 0.0500154087434401,
957 0.0336638535386336,
958 0.0524451689724362,
959 0.0975047615363696,
960 0.0534062948099107,
961 0.0728496986560913,
962 0.133492464750329,
963 0.0136337226253535,
964 0.068520339745976,
965 0.0280483371477404,
966 0.102625433672842,
967 0.106450145288677,
968 0.183534057756558,
969 0.135468059382387,
970 0.0877555778478272,
971 0.0278059231072768,
972 0.0277019054815613,
973 ];
974 const SCORE: [f64; 64] = [
975 0.0,
976 0.365970771264122,
977 0.0,
978 1.05881285226553,
979 0.796450465672768,
980 0.0,
981 0.15195611134601,
982 0.0,
983 0.0,
984 0.0,
985 0.0,
986 0.503171625125652,
987 0.602838806095091,
988 0.0,
989 0.22119300766101,
990 0.238060922935183,
991 0.0,
992 0.223493163232391,
993 0.0,
994 0.0,
995 0.559450528883167,
996 0.413466232376651,
997 0.566393797404165,
998 0.0,
999 0.402200065392971,
1000 0.0,
1001 0.0,
1002 0.758500430509727,
1003 1.5606529881759,
1004 0.830851346829295,
1005 6.79293349706241,
1006 0.199289129287858,
1007 0.0,
1008 0.0,
1009 0.0,
1010 0.402864454850786,
1011 0.253212692235468,
1012 0.701706509860138,
1013 0.0,
1014 0.0,
1015 0.0,
1016 0.0,
1017 0.0,
1018 0.0,
1019 0.0,
1020 0.0,
1021 0.0,
1022 0.0,
1023 0.0,
1024 0.0,
1025 0.0,
1026 0.0,
1027 0.0,
1028 0.315186058686334,
1029 0.0,
1030 0.0,
1031 0.0,
1032 0.0305605858088266,
1033 0.0553323044375994,
1034 0.76882472342237,
1035 0.298864697731819,
1036 0.0,
1037 0.0,
1038 0.0,
1039 ];
1040
1041 fn close(got: f64, want: f64) -> bool {
1042 (got - want).abs() <= 1e-8 * want.abs().max(1.0)
1043 }
1044
1045 #[test]
1046 fn saliency_and_scores_reproduce_the_published_method() {
1047 let points = SpectralResidual::new()
1048 .analyze(&SERIES)
1049 .expect("a valid series");
1050 for (i, p) in points.iter().enumerate() {
1051 assert!(
1052 close(p.saliency, SALIENCY[i]),
1053 "saliency[{i}]: got {}, reference {}",
1054 p.saliency,
1055 SALIENCY[i]
1056 );
1057 assert!(
1058 close(p.score, SCORE[i]),
1059 "score[{i}]: got {}, reference {}",
1060 p.score,
1061 SCORE[i]
1062 );
1063 }
1064 let flagged: Vec<usize> = points
1065 .iter()
1066 .filter(|p| p.is_anomaly)
1067 .map(|p| p.index)
1068 .collect();
1069 assert_eq!(flagged, vec![30]);
1070 }
1071}