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 peak = spectrum.iter().map(|z| z.norm()).fold(0.0_f64, f64::max);
541 let floor = if peak > 0.0 { 1e-8 * peak } else { 1e-8 };
542 let log_amp: Vec<f64> = spectrum.iter().map(|z| (z.norm() + floor).ln()).collect();
543 let mut avg = vec![0.0; len];
545 let mut cum = 0.0;
546 let mut cums = Vec::with_capacity(len + 1);
547 cums.push(0.0);
548 for &v in &log_amp {
549 cum += v;
550 cums.push(cum);
551 }
552 for i in 0..len {
553 let from = (i + 1).saturating_sub(q);
554 avg[i] = (cums[i + 1] - cums[from]) / (i + 1 - from) as f64;
555 }
556 let residual_spectrum: Vec<Complex> = spectrum
557 .iter()
558 .enumerate()
559 .map(|(i, z)| {
560 let r = (log_amp[i] - avg[i]).exp();
561 let phase = z.arg();
562 Complex::new(r * phase.cos(), r * phase.sin())
563 })
564 .collect();
565 ifft(&residual_spectrum)
566 .into_iter()
567 .take(n)
568 .map(|z| z.norm())
569 .collect()
570}
571
572fn expected_values(data: &[f64], flagged: &[bool]) -> Vec<f64> {
577 let n = data.len();
578 let mut cleaned = data.to_vec();
579 for i in 0..n {
580 if !flagged[i] {
581 continue;
582 }
583 let left = (0..i).rev().find(|&j| !flagged[j]).map(|j| data[j]);
584 let right = (i + 1..n).find(|&j| !flagged[j]).map(|j| data[j]);
585 cleaned[i] = match (left, right) {
586 (Some(a), Some(b)) => (a + b) / 2.0,
587 (Some(a), None) | (None, Some(a)) => a,
588 (None, None) => data[i],
589 };
590 }
591 let mut spectrum = fft(&cleaned
592 .iter()
593 .map(|&x| Complex::real(x))
594 .collect::<Vec<_>>());
595 let lo = n * 3 / 8;
596 let hi = n * 5 / 8;
597 for (i, z) in spectrum.iter_mut().enumerate() {
598 if i > lo && i < hi {
599 *z = Complex::default();
600 }
601 }
602 ifft(&spectrum).into_iter().map(|z| z.re).collect()
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608
609 fn noise(seed: u64, n: usize) -> Vec<f64> {
610 let mut state = seed;
611 (0..n)
612 .map(|_| {
613 state = state
614 .wrapping_mul(6364136223846793005)
615 .wrapping_add(1442695040888963407);
616 ((state >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
617 })
618 .collect()
619 }
620
621 fn check_shape(points: &[SrPoint], n: usize) {
622 assert_eq!(points.len(), n);
623 for (i, p) in points.iter().enumerate() {
624 assert_eq!(p.index, i);
625 assert!(p.saliency >= 0.0 && p.saliency.is_finite(), "{p:?}");
626 assert!(p.score >= 0.0 && p.score.is_finite(), "{p:?}");
627 assert!(
628 p.expected.is_finite() && p.lower <= p.expected && p.expected <= p.upper,
629 "{p:?}"
630 );
631 }
632 }
633
634 #[test]
638 fn the_unit_of_the_series_does_not_change_the_answer() {
639 let mut series: Vec<f64> = noise(3, 120)
640 .iter()
641 .enumerate()
642 .map(|(t, e)| (t as f64 * 0.25).sin() + 0.1 * e)
643 .collect();
644 series[80] += 5.0;
645 let base = SpectralResidual::new().analyze(&series).unwrap();
646 for k in [1e-15, 1e-12, 1e-9, 1e-6, 1e6, 1e12] {
647 let scaled: Vec<f64> = series.iter().map(|v| v * k).collect();
648 let got = SpectralResidual::new().analyze(&scaled).unwrap();
649 for (g, b) in got.iter().zip(&base) {
650 assert_eq!(g.is_anomaly, b.is_anomaly, "scale {k:e}, point {}", b.index);
651 assert!(
652 (g.saliency - b.saliency).abs() <= 1e-6 * b.saliency.max(1e-3),
653 "scale {k:e}, point {}: saliency {} vs {}",
654 b.index,
655 g.saliency,
656 b.saliency
657 );
658 assert!(
659 (g.expected / k - b.expected).abs() <= 1e-6 * b.expected.abs().max(1.0),
660 "scale {k:e}, point {}: expected",
661 b.index
662 );
663 }
664 }
665 }
666
667 #[test]
668 fn a_spike_on_a_sine_is_the_anomaly() {
669 let mut series: Vec<f64> = noise(3, 120)
670 .iter()
671 .enumerate()
672 .map(|(t, e)| (t as f64 * 0.25).sin() + 0.1 * e)
673 .collect();
674 series[80] += 5.0;
675 let points = SpectralResidual::new().analyze(&series).unwrap();
676 check_shape(&points, 120);
677 assert!(points[80].is_anomaly, "{:?}", points[80]);
678 let flagged: Vec<usize> = points
679 .iter()
680 .filter(|p| p.is_anomaly)
681 .map(|p| p.index)
682 .collect();
683 assert!(flagged.len() <= 3, "flagged {flagged:?}");
684 assert!(points[80].value > points[80].upper);
685 assert!(
687 (points[80].expected - (80.0f64 * 0.25).sin()).abs() < 0.6,
688 "{:?}",
689 points[80]
690 );
691 }
692
693 #[test]
694 fn a_step_is_flagged_where_it_happens() {
695 let series: Vec<f64> = (0..100).map(|t| if t < 60 { 1.0 } else { 6.0 }).collect();
696 let points = SpectralResidual::new().analyze(&series).unwrap();
697 check_shape(&points, 100);
698 let first = points.iter().position(|p| p.is_anomaly);
699 assert_eq!(
700 first,
701 Some(60),
702 "{:?}",
703 points
704 .iter()
705 .filter(|p| p.is_anomaly)
706 .map(|p| p.index)
707 .collect::<Vec<_>>()
708 );
709 }
710
711 #[test]
712 fn a_constant_and_plain_noise_raise_no_anomaly() {
713 let constant = vec![3.0; 50];
714 let points = SpectralResidual::new().analyze(&constant).unwrap();
715 check_shape(&points, 50);
716 assert!(points.iter().all(|p| !p.is_anomaly));
717 assert!(points.iter().all(|p| (p.expected - 3.0).abs() < 1e-9));
718
719 let series = noise(9, 200);
720 let points = SpectralResidual::new().analyze(&series).unwrap();
721 check_shape(&points, 200);
722 let flagged = points.iter().filter(|p| p.is_anomaly).count();
723 assert!(flagged <= 4, "{flagged} of 200 noise points flagged");
724 }
725
726 #[test]
727 fn scoring_is_deterministic_and_batching_keeps_the_shape() {
728 let mut series: Vec<f64> = noise(5, 300).iter().map(|e| 10.0 + e).collect();
729 series[150] = 20.0;
730 series[260] = -5.0;
731 let sr = SpectralResidual::new();
732 let a = sr.analyze(&series).unwrap();
733 let b = sr.analyze(&series).unwrap();
734 assert_eq!(a, b);
735 let batched = sr.with_batch_size(Some(100)).analyze(&series).unwrap();
736 check_shape(&batched, 300);
737 assert!(batched[150].is_anomaly && batched[260].is_anomaly);
738 assert_eq!(batched[200].index, 200);
741 }
742
743 #[test]
744 fn the_band_widens_with_sensitivity() {
745 let series: Vec<f64> = noise(1, 80).iter().map(|e| 5.0 + e).collect();
746 let narrow = SpectralResidual::new()
747 .with_sensitivity(50.0)
748 .analyze(&series)
749 .unwrap();
750 let wide = SpectralResidual::new()
751 .with_sensitivity(99.0)
752 .analyze(&series)
753 .unwrap();
754 for (a, b) in narrow.iter().zip(&wide) {
755 assert!(b.upper - b.lower > a.upper - a.lower);
756 assert_eq!(a.expected, b.expected);
757 }
758 }
759
760 #[test]
764 fn points_near_either_end_of_a_batch_are_marked() {
765 let series: Vec<f64> = (0..200).map(|i| (i % 7) as f64).collect();
766 let points = SpectralResidual::new().analyze(&series).expect("ok");
767 let marked: Vec<usize> = points
768 .iter()
769 .filter(|p| p.near_edge)
770 .map(|p| p.index)
771 .collect();
772 assert_eq!(marked, vec![0, 1, 2, 3, 4, 195, 196, 197, 198, 199]);
773
774 let batched = SpectralResidual::new()
776 .with_batch_size(Some(50))
777 .analyze(&series)
778 .expect("ok");
779 let marked: Vec<usize> = batched
780 .iter()
781 .filter(|p| p.near_edge)
782 .map(|p| p.index)
783 .collect();
784 assert!(marked.contains(&49) && marked.contains(&50), "{marked:?}");
787 assert!(!marked.contains(&75), "{marked:?}");
788 assert_eq!(
789 marked.len(),
790 4 * 10,
791 "four batches, ten marks each: {marked:?}"
792 );
793
794 let mut spiked: Vec<f64> = (0..60).map(|i| (i % 7) as f64).collect();
797 spiked[57] += 30.0;
798 let points = SpectralResidual::new().analyze(&spiked).expect("ok");
799 assert!(points[57].is_anomaly);
800 assert!(points[57].near_edge);
801 }
802
803 #[test]
806 fn a_step_can_flag_the_point_before_the_transition() {
807 let step: Vec<f64> = (0..120)
808 .map(|i| {
809 let jitter = ((i as f64 * 7.3891).sin() * 10000.0).fract().abs() * 0.2;
810 (if i < 60 { 1.0 } else { 6.0 }) + jitter
811 })
812 .collect();
813 let points = SpectralResidual::new().analyze(&step).expect("ok");
814 let flagged: Vec<usize> = points
815 .iter()
816 .filter(|p| p.is_anomaly && !p.near_edge)
817 .map(|p| p.index)
818 .collect();
819 assert!(!flagged.is_empty(), "the transition is detected");
820 assert!(
821 flagged.iter().all(|&i| (59..=61).contains(&i)),
822 "detections sit at the transition or its neighbour: {flagged:?}"
823 );
824 }
825
826 #[test]
829 fn a_refusal_names_the_condition_that_failed() {
830 let ok = vec![1.0; 20];
831 use SpectralResidualError as E;
832
833 assert_eq!(
834 SpectralResidual::new().analyze(&[1.0; 11]),
835 Err(SpectralResidualError::TooFewObservations {
836 needed: MIN_OBSERVATIONS,
837 got: 11
838 })
839 );
840
841 let mut series = vec![1.0; 20];
842 series[7] = f64::INFINITY;
843 assert_eq!(
844 SpectralResidual::new().analyze(&series),
845 Err(SpectralResidualError::ValueNotFinite { index: 7 })
846 );
847
848 assert_eq!(
852 SpectralResidual::new().with_threshold(0.0).analyze(&ok),
853 Err(E::not_above("threshold", "a finite number > 0", 0.0, 0.0))
854 );
855 let sensitivity = |got| {
856 Err(E::not_between(
857 "sensitivity",
858 "a finite number strictly between 0 and 100",
859 got,
860 0.0,
861 100.0,
862 ))
863 };
864 assert_eq!(
865 SpectralResidual::new().with_sensitivity(100.0).analyze(&ok),
866 sensitivity(100.0)
867 );
868 assert_eq!(
869 SpectralResidual::new().with_sensitivity(0.0).analyze(&ok),
870 sensitivity(0.0)
871 );
872 assert_eq!(
873 SpectralResidual::new()
874 .with_batch_size(Some(5))
875 .analyze(&ok),
876 Err(E::below("batch_size", "unset, or at least 12", 5.0, 12.0))
877 );
878 assert_eq!(
879 SpectralResidual::new()
880 .with_averaging_window(0)
881 .analyze(&ok),
882 Err(E::below("averaging_window", ">= 1", 0.0, 1.0))
883 );
884 assert_eq!(
885 SpectralResidual::new()
886 .with_judgement_window(0)
887 .analyze(&ok),
888 Err(E::below("judgement_window", ">= 1", 0.0, 1.0))
889 );
890 assert_eq!(
891 SpectralResidual::new().with_min_zscore(-1.0).analyze(&ok),
892 Err(E::below("min_zscore", "a finite number >= 0", -1.0, 0.0))
893 );
894 assert!(matches!(
896 SpectralResidual::new()
897 .with_threshold(f64::NAN)
898 .analyze(&ok),
899 Err(E::OptionOutOfRange {
900 option: "threshold",
901 min: None,
902 exclusive_min,
903 max: None,
904 exclusive_max: None,
905 got,
906 ..
907 }) if got.is_nan() && exclusive_min == Some(0.0)
908 ));
909
910 let message = SpectralResidual::new()
912 .with_threshold(0.0)
913 .analyze(&ok)
914 .expect_err("refused")
915 .to_string();
916 assert_eq!(message, "threshold must be a finite number > 0");
917 assert!(!message.contains("sensitivity"));
918
919 assert!(SpectralResidual::new()
921 .with_min_zscore(0.0)
922 .analyze(&ok)
923 .is_ok());
924 }
925}
926
927#[cfg(test)]
936mod numpy_reference {
937 use super::*;
938
939 const SERIES: [f64; 64] = [
940 0.061, 0.585, 0.52, 1.134, 1.216, 0.847, 0.806, 0.369, -0.109, -0.265, -0.526, -1.008,
941 -1.139, -0.838, -0.869, -0.672, -0.068, 0.575, 0.594, 0.806, 0.924, 0.716, 0.839, 0.411,
942 0.217, -0.158, -0.583, -1.146, -1.238, -0.975, 3.168, -0.527, 0.231, 0.418, 0.492, 1.129,
943 0.898, 1.212, 0.745, 0.17, -0.134, -0.308, -0.865, -0.867, -0.867, -0.913, -0.746, -0.503,
944 0.119, 0.423, 0.811, 0.793, 0.878, 1.089, 0.49, 0.568, 0.154, -0.484, -0.677, -1.11,
945 -0.991, -0.84, -0.504, -0.44,
946 ];
947 const SALIENCY: [f64; 64] = [
948 0.0369614318108603,
949 0.0796307697333925,
950 0.0271892659280146,
951 0.152493866186463,
952 0.166141948303089,
953 0.0816656918320778,
954 0.107174248120332,
955 0.0556332303417036,
956 0.0251300576938508,
957 0.00299138253005822,
958 0.00594392958654813,
959 0.106106695034459,
960 0.119126565861323,
961 0.0424769851751049,
962 0.0893964151003082,
963 0.0920927965046129,
964 0.0198418341809379,
965 0.0882438287553024,
966 0.0549823475501431,
967 0.067869463097445,
968 0.113994918150788,
969 0.105398733553807,
970 0.119888725456503,
971 0.0746552451898384,
972 0.109038966340197,
973 0.0173152365850345,
974 0.0229006048210958,
975 0.132971301432821,
976 0.205056469142638,
977 0.150938526823712,
978 0.830517907514111,
979 0.128639067122104,
980 0.0093039215691647,
981 0.0611436432409572,
982 0.0458141563355618,
983 0.143893509514701,
984 0.129454085999137,
985 0.179180669344178,
986 0.105280578308881,
987 0.0679545755367448,
988 0.0275280318011067,
989 0.0125531569333055,
990 0.0908993020495842,
991 0.0747621046819059,
992 0.0497022369555018,
993 0.0789105027673606,
994 0.0665323274213843,
995 0.0500154087434401,
996 0.0336638535386336,
997 0.0524451689724362,
998 0.0975047615363696,
999 0.0534062948099107,
1000 0.0728496986560913,
1001 0.133492464750329,
1002 0.0136337226253535,
1003 0.068520339745976,
1004 0.0280483371477404,
1005 0.102625433672842,
1006 0.106450145288677,
1007 0.183534057756558,
1008 0.135468059382387,
1009 0.0877555778478272,
1010 0.0278059231072768,
1011 0.0277019054815613,
1012 ];
1013 const SCORE: [f64; 64] = [
1014 0.0,
1015 0.365970771264122,
1016 0.0,
1017 1.05881285226553,
1018 0.796450465672768,
1019 0.0,
1020 0.15195611134601,
1021 0.0,
1022 0.0,
1023 0.0,
1024 0.0,
1025 0.503171625125652,
1026 0.602838806095091,
1027 0.0,
1028 0.22119300766101,
1029 0.238060922935183,
1030 0.0,
1031 0.223493163232391,
1032 0.0,
1033 0.0,
1034 0.559450528883167,
1035 0.413466232376651,
1036 0.566393797404165,
1037 0.0,
1038 0.402200065392971,
1039 0.0,
1040 0.0,
1041 0.758500430509727,
1042 1.5606529881759,
1043 0.830851346829295,
1044 6.79293349706241,
1045 0.199289129287858,
1046 0.0,
1047 0.0,
1048 0.0,
1049 0.402864454850786,
1050 0.253212692235468,
1051 0.701706509860138,
1052 0.0,
1053 0.0,
1054 0.0,
1055 0.0,
1056 0.0,
1057 0.0,
1058 0.0,
1059 0.0,
1060 0.0,
1061 0.0,
1062 0.0,
1063 0.0,
1064 0.0,
1065 0.0,
1066 0.0,
1067 0.315186058686334,
1068 0.0,
1069 0.0,
1070 0.0,
1071 0.0305605858088266,
1072 0.0553323044375994,
1073 0.76882472342237,
1074 0.298864697731819,
1075 0.0,
1076 0.0,
1077 0.0,
1078 ];
1079
1080 fn close(got: f64, want: f64) -> bool {
1085 (got - want).abs() <= 1e-6 * want.abs().max(1.0)
1086 }
1087
1088 #[test]
1089 fn saliency_and_scores_reproduce_the_published_method() {
1090 let points = SpectralResidual::new()
1091 .analyze(&SERIES)
1092 .expect("a valid series");
1093 for (i, p) in points.iter().enumerate() {
1094 assert!(
1095 close(p.saliency, SALIENCY[i]),
1096 "saliency[{i}]: got {}, reference {}",
1097 p.saliency,
1098 SALIENCY[i]
1099 );
1100 assert!(
1101 close(p.score, SCORE[i]),
1102 "score[{i}]: got {}, reference {}",
1103 p.score,
1104 SCORE[i]
1105 );
1106 }
1107 let flagged: Vec<usize> = points
1108 .iter()
1109 .filter(|p| p.is_anomaly)
1110 .map(|p| p.index)
1111 .collect();
1112 assert_eq!(flagged, vec![30]);
1113 }
1114}