1use u_numflow::fourier::rfft;
44use u_numflow::random::{create_rng, shuffle};
45
46use crate::correlation::acf;
47
48pub const MIN_OBSERVATIONS: usize = 8;
51
52const PERMUTATIONS: usize = 100;
54
55const PERMUTATION_QUANTILE: f64 = 0.99;
57
58const PERMUTATION_SEED: u64 = 0x5EA5_0A11;
61
62const MAX_CANDIDATES: usize = 3;
64
65#[derive(Debug, Clone, PartialEq)]
67pub struct Periodogram {
68 pub n: usize,
70 pub padded_len: usize,
72 pub power: Vec<f64>,
75 pub frequency: Vec<f64>,
77}
78
79impl Periodogram {
80 pub fn period_of_bin(&self, k: usize) -> f64 {
82 self.padded_len as f64 / k as f64
83 }
84
85 pub fn peak_bin(&self) -> Option<usize> {
88 let (i, &p) = self
89 .power
90 .iter()
91 .enumerate()
92 .max_by(|a, b| a.1.partial_cmp(b.1).expect("finite power"))?;
93 (p > 0.0).then_some(i + 1)
94 }
95}
96
97#[derive(Debug, Clone, PartialEq)]
99pub struct PeriodCandidate {
100 pub period: usize,
102 pub acf: f64,
107 pub bin: usize,
109 pub power: f64,
111 pub power_share: f64,
113}
114
115#[derive(Debug, Clone, PartialEq)]
117pub struct SeasonalityResult {
118 pub period: Option<usize>,
121 pub candidates: Vec<PeriodCandidate>,
124 pub n: usize,
126 pub acf_threshold: f64,
128 pub power_threshold: f64,
131}
132
133pub fn periodogram(series: &[f64]) -> Option<Periodogram> {
151 let n = series.len();
152 if n < MIN_OBSERVATIONS || series.iter().any(|v| !v.is_finite()) {
153 return None;
154 }
155 let detrended = detrend(series);
156 let padded_len = padded_len(n);
157 let power = padded_power(&detrended, padded_len);
158 let frequency: Vec<f64> = (1..=padded_len / 2)
159 .map(|k| k as f64 / padded_len as f64)
160 .collect();
161 Some(Periodogram {
162 n,
163 padded_len,
164 power,
165 frequency,
166 })
167}
168
169pub fn estimate_period(series: &[f64]) -> Option<SeasonalityResult> {
195 let pg = periodogram(series)?;
196 let n = pg.n;
197 let acf_threshold = 1.96 / (n as f64).sqrt();
198 let detrended = detrend(series);
199 let total: f64 = pg.power.iter().sum();
200 let power_threshold = if total > 0.0 && total.is_finite() {
201 permutation_threshold(&detrended)
202 } else {
203 f64::INFINITY
204 };
205 let no_period = || SeasonalityResult {
206 period: None,
207 candidates: Vec::new(),
208 n,
209 acf_threshold,
210 power_threshold,
211 };
212 if !power_threshold.is_finite() {
213 return Some(no_period());
214 }
215
216 let m = pg.padded_len;
219 let mut peaks: Vec<(usize, f64)> = pg
220 .power
221 .iter()
222 .enumerate()
223 .filter(|&(i, &p)| {
224 p > power_threshold
225 && (i == 0 || p >= pg.power[i - 1])
226 && pg.power.get(i + 1).is_none_or(|&next| p >= next)
227 })
228 .map(|(i, &p)| (i + 1, p))
229 .collect();
230 peaks.sort_by(|a, b| b.1.partial_cmp(&a.1).expect("finite power"));
231 peaks.truncate(MAX_CANDIDATES);
232 if peaks.is_empty() {
233 return Some(no_period());
234 }
235
236 let Some(acf_result) = acf(&detrended, n / 2) else {
238 return Some(no_period());
239 };
240 let r = &acf_result.acf;
241
242 let mut candidates: Vec<PeriodCandidate> = Vec::new();
243 for (bin, power) in peaks {
244 let lo = (m / (bin + 1)).max(2);
245 let hi = if bin == 1 {
246 n / 2
247 } else {
248 (m as f64 / (bin - 1) as f64).ceil() as usize
249 }
250 .min(n / 2);
251 if lo > hi {
252 continue;
253 }
254 let corrected_at = |lag: usize| r[lag] * n as f64 / (n - lag) as f64;
262 let Some(lag) = (lo..=hi).filter(|&lag| lag < r.len()).max_by(|&a, &b| {
263 corrected_at(a)
264 .partial_cmp(&corrected_at(b))
265 .expect("finite acf")
266 }) else {
267 continue;
268 };
269 let corrected = corrected_at(lag);
270 let is_hill_top = corrected > acf_threshold
271 && corrected >= corrected_at(lag - 1)
272 && (lag + 1 >= r.len() || corrected >= corrected_at(lag + 1));
273 if !is_hill_top {
274 continue;
275 }
276 if let Some(existing) = candidates.iter_mut().find(|c| c.period == lag) {
277 if power > existing.power {
279 existing.bin = bin;
280 existing.power = power;
281 existing.power_share = power / total;
282 }
283 continue;
284 }
285 candidates.push(PeriodCandidate {
286 period: lag,
287 acf: corrected,
288 bin,
289 power,
290 power_share: power / total,
291 });
292 }
293
294 candidates.sort_by(|a, b| b.acf.partial_cmp(&a.acf).expect("finite acf"));
295 let period = candidates.first().map(|c| c.period);
296 Some(SeasonalityResult {
297 period,
298 candidates,
299 n,
300 acf_threshold,
301 power_threshold,
302 })
303}
304
305fn detrend(series: &[f64]) -> Vec<f64> {
307 let n = series.len() as f64;
308 let t_mean = (n - 1.0) / 2.0;
309 let y_mean = series.iter().sum::<f64>() / n;
310 let (mut sxy, mut sxx) = (0.0, 0.0);
311 for (i, &y) in series.iter().enumerate() {
312 let dt = i as f64 - t_mean;
313 sxy += dt * (y - y_mean);
314 sxx += dt * dt;
315 }
316 let slope = if sxx > 0.0 { sxy / sxx } else { 0.0 };
317 series
318 .iter()
319 .enumerate()
320 .map(|(i, &y)| y - y_mean - slope * (i as f64 - t_mean))
321 .collect()
322}
323
324fn padded_len(n: usize) -> usize {
326 (4 * n).next_power_of_two()
327}
328
329fn padded_power(detrended: &[f64], m: usize) -> Vec<f64> {
331 let n = detrended.len();
332 let mut padded = vec![0.0; m];
333 padded[..n].copy_from_slice(detrended);
334 rfft(&padded)[1..=m / 2]
335 .iter()
336 .map(|z| z.norm_sqr() / n as f64)
337 .collect()
338}
339
340fn permutation_threshold(detrended: &[f64]) -> f64 {
344 let m = padded_len(detrended.len());
345 let mut rng = create_rng(PERMUTATION_SEED);
346 let mut shuffled = detrended.to_vec();
347 let mut maxima: Vec<f64> = (0..PERMUTATIONS)
348 .map(|_| {
349 shuffle(&mut shuffled, &mut rng);
350 padded_power(&shuffled, m)
351 .into_iter()
352 .fold(0.0f64, f64::max)
353 })
354 .collect();
355 maxima.sort_by(|a, b| a.partial_cmp(b).expect("finite power"));
356 let idx =
357 ((PERMUTATIONS as f64 * PERMUTATION_QUANTILE).ceil() as usize).clamp(1, PERMUTATIONS) - 1;
358 maxima[idx]
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use std::f64::consts::PI;
365
366 fn noise(seed: u64, n: usize) -> Vec<f64> {
369 let mut state = seed;
370 (0..n)
371 .map(|_| {
372 state = state
373 .wrapping_mul(6364136223846793005)
374 .wrapping_add(1442695040888963407);
375 ((state >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
376 })
377 .collect()
378 }
379
380 #[test]
381 fn sawtooth_of_period_seven_is_found() {
382 let series: Vec<f64> = (0..40).map(|i| (i % 7) as f64).collect();
384 let r = estimate_period(&series).unwrap();
385 assert_eq!(r.period, Some(7), "{r:?}");
386 assert!(r.candidates[0].acf > 0.5);
387 assert!(r.candidates[0].power > r.power_threshold);
388 }
389
390 #[test]
391 fn sine_with_noise_is_found_and_the_period_is_exact() {
392 let n = 120;
393 let series: Vec<f64> = noise(7, n)
394 .iter()
395 .enumerate()
396 .map(|(t, e)| (2.0 * PI * t as f64 / 12.0).sin() + 0.3 * e)
397 .collect();
398 let r = estimate_period(&series).unwrap();
399 assert_eq!(r.period, Some(12), "{r:?}");
400 }
401
402 #[test]
403 fn square_wave_reports_the_fundamental_first() {
404 let series: Vec<f64> = (0..100)
405 .map(|t| if (t / 5) % 2 == 0 { 1.0 } else { -1.0 })
406 .collect();
407 let r = estimate_period(&series).unwrap();
408 assert_eq!(r.period, Some(10), "{r:?}");
409 }
410
411 #[test]
412 fn period_survives_a_linear_trend() {
413 let series: Vec<f64> = (0..60)
414 .map(|t| 0.5 * t as f64 + 3.0 * (2.0 * PI * t as f64 / 6.0).cos())
415 .collect();
416 let r = estimate_period(&series).unwrap();
417 assert_eq!(r.period, Some(6), "{r:?}");
418 }
419
420 #[test]
421 fn line_constant_and_white_noise_have_no_period() {
422 let line: Vec<f64> = (0..50).map(|t| 3.0 - 0.25 * t as f64).collect();
423 assert_eq!(estimate_period(&line).unwrap().period, None);
424
425 let constant = vec![4.2; 30];
426 let r = estimate_period(&constant).unwrap();
427 assert_eq!(r.period, None);
428 assert!(r.candidates.is_empty());
429
430 let r = estimate_period(&noise(11, 200)).unwrap();
433 assert_eq!(r.period, None, "{r:?}");
434 }
435
436 #[test]
437 fn every_period_from_2_to_20_is_recovered_from_sines_and_sawtooths() {
438 for period in 2..=20usize {
439 for cycles in [4usize, 7, 13] {
440 let n = period * cycles;
441 if n < 16 {
442 continue;
445 }
446 let sine: Vec<f64> = noise(period as u64 * 31 + cycles as u64, n)
447 .iter()
448 .enumerate()
449 .map(|(t, e)| (2.0 * PI * t as f64 / period as f64 + 0.7).sin() + 0.2 * e)
450 .collect();
451 let r = estimate_period(&sine).unwrap();
452 assert_eq!(r.period, Some(period), "sine period {period}, n {n}: {r:?}");
453
454 let saw: Vec<f64> = (0..n + 3).map(|t| (t % period) as f64).collect();
455 let r = estimate_period(&saw).unwrap();
456 assert_eq!(
457 r.period,
458 Some(period),
459 "sawtooth period {period}, n {}: {r:?}",
460 n + 3
461 );
462 }
463 }
464 }
465
466 #[test]
473 fn a_period_is_exact_when_the_series_is_not_a_whole_number_of_cycles() {
474 fn jitter(i: usize) -> f64 {
475 (((i as f64) * 12.9898).sin() * 43758.5453) % 1.0
476 }
477 let spikes = [50usize, 120, 200, 250];
478 let reported: Vec<f64> = (0..300)
479 .map(|i| {
480 25.0 + 2.2 * (2.0 * PI * i as f64 / 63.0).sin()
481 + 0.4 * jitter(i)
482 + if spikes.contains(&i) { 12.0 } else { 0.0 }
483 })
484 .collect();
485 let r = estimate_period(&reported).unwrap();
486 assert_eq!(r.period, Some(63), "{r:?}");
487
488 let whole: Vec<f64> = (0..315)
491 .map(|i| 25.0 + 2.2 * (2.0 * PI * i as f64 / 63.0).sin() + 0.4 * jitter(i))
492 .collect();
493 assert_eq!(estimate_period(&whole).unwrap().period, Some(63));
494
495 for period in [11usize, 17, 23, 31, 47, 63] {
496 let n = ((period as f64) * 4.76).round() as usize;
497 let sine: Vec<f64> = noise(period as u64 * 17 + 3, n)
498 .iter()
499 .enumerate()
500 .map(|(t, e)| (2.0 * PI * t as f64 / period as f64 + 0.7).sin() + 0.2 * e)
501 .collect();
502 let r = estimate_period(&sine).unwrap();
503 assert_eq!(r.period, Some(period), "period {period}, n {n}: {r:?}");
504 }
505 }
506
507 #[test]
510 fn a_candidate_reports_the_autocorrelation_it_was_accepted_on() {
511 let series: Vec<f64> = (0..137)
512 .map(|t| (2.0 * PI * t as f64 / 19.0).sin())
513 .collect();
514 let r = estimate_period(&series).unwrap();
515 assert_eq!(r.period, Some(19));
516 for c in &r.candidates {
517 assert!(
518 c.acf > r.acf_threshold,
519 "candidate {} reports {} against a bound of {}",
520 c.period,
521 c.acf,
522 r.acf_threshold
523 );
524 }
525 }
526
527 #[test]
528 fn short_or_non_finite_input_cannot_be_analysed() {
529 assert!(estimate_period(&[1.0; 7]).is_none());
530 assert!(periodogram(&[1.0; 7]).is_none());
531 let mut series: Vec<f64> = (0..20).map(|i| (i % 4) as f64).collect();
532 series[3] = f64::NAN;
533 assert!(estimate_period(&series).is_none());
534 }
535
536 #[test]
537 fn periodogram_bins_and_frequencies_line_up() {
538 let series: Vec<f64> = (0..48)
539 .map(|t| (2.0 * PI * t as f64 / 12.0).sin())
540 .collect();
541 let p = periodogram(&series).unwrap();
542 assert_eq!(p.padded_len, 256);
543 assert_eq!(p.power.len(), 128);
544 assert_eq!(p.frequency.len(), 128);
545 assert!((p.frequency[20] - 21.0 / 256.0).abs() < 1e-15);
546 let peak = p.peak_bin().unwrap();
547 assert_eq!(peak, 21);
548 assert!((p.period_of_bin(peak) - 12.0).abs() < 0.3);
549 assert!(
552 p.power[peak - 1] > 10.5 && p.power[peak - 1] <= 12.0,
553 "{}",
554 p.power[peak - 1]
555 );
556 }
557}
558
559#[cfg(test)]
566#[allow(clippy::approx_constant)]
568mod numpy_reference {
569 use super::*;
570
571 const PG_SERIES: [f64; 30] = [
572 4.872, 5.961, 5.825, 5.144, 4.146, 4.708, 5.075, 5.872, 6.02, 5.134, 4.962, 4.284, 4.897,
573 6.327, 6.276, 5.223, 4.726, 4.67, 5.472, 6.109, 6.148, 5.732, 4.675, 4.688, 5.575, 6.406,
574 6.708, 5.811, 4.756, 4.49,
575 ];
576 const PG_POWER: [f64; 64] = [
577 0.000284033512117391,
578 0.00553405987724846,
579 0.0302722144153745,
580 0.0831085833321147,
581 0.133085087378873,
582 0.123289480834556,
583 0.0533851897200275,
584 0.01637782366091,
585 0.087488531746546,
586 0.190533285812008,
587 0.175364989155614,
588 0.0614983006464574,
589 0.0775763298722834,
590 0.330671796059938,
591 0.537579817078249,
592 0.350336825311503,
593 0.0246598611773083,
594 0.474810160665647,
595 2.31103325540996,
596 4.83383291955508,
597 6.33862799215007,
598 5.67039790499692,
599 3.37456537314838,
600 1.12427168848378,
601 0.0891876510214037,
602 0.0814269240692162,
603 0.235139118197625,
604 0.131491953209051,
605 0.0138091767541502,
606 0.140722132759862,
607 0.354827745604986,
608 0.371281570269601,
609 0.200282895493422,
610 0.0741310942371521,
611 0.086678695001399,
612 0.116160506813996,
613 0.0716411804794686,
614 0.0245416422268119,
615 0.0531807286271523,
616 0.102252619738399,
617 0.0838655381832552,
618 0.0281521341166196,
619 0.0335787253260554,
620 0.110927863849889,
621 0.169919048372977,
622 0.149793848613065,
623 0.0851619721280352,
624 0.0362266166967703,
625 0.0194195967158166,
626 0.0241675506804475,
627 0.0424368069304849,
628 0.0560057396007925,
629 0.0401962394364771,
630 0.00919485591496622,
631 0.0165631435231124,
632 0.0766705537531582,
633 0.12478599870531,
634 0.100432590523266,
635 0.0383177907494266,
636 0.0192509305437197,
637 0.0504432405513664,
638 0.0630009356163572,
639 0.0282428274414305,
640 0.00221180307769972,
641 ];
642 const P12: [f64; 120] = [
643 -0.646, 0.989, 0.558, 1.047, 0.831, 0.359, 0.402, -0.871, -1.05, -1.197, -0.95, -0.373,
644 -0.499, 0.521, 0.755, 1.054, 0.744, 1.032, -0.269, -0.377, -0.816, -0.701, -1.722, -0.523,
645 -0.196, 1.257, 0.572, 0.928, 0.32, 0.553, -0.575, -0.699, -0.782, -1.035, -0.956, -0.929,
646 0.084, 0.964, 1.454, 0.834, 0.347, 0.275, 0.001, -0.06, -1.261, -0.974, -1.11, -0.107,
647 -0.485, 0.75, 1.173, 0.926, 0.917, 0.431, -0.318, -0.357, -0.72, -1.194, -0.913, -0.324,
648 0.038, 0.502, 0.616, 0.972, 1.212, 0.708, -0.438, -0.696, -1.184, -0.46, -0.18, -0.604,
649 0.112, 0.374, 1.036, 1.533, 0.869, 0.928, -0.06, -0.609, -0.397, -1.135, -1.117, -0.784,
650 -0.179, 0.484, 1.204, 1.122, 1.094, 0.359, -0.081, -0.472, -0.45, -0.628, -0.729, -0.764,
651 0.158, 0.663, 1.035, 1.097, 0.704, 0.632, 0.296, -0.587, -0.489, -1.33, -0.983, -0.562,
652 -0.068, 0.927, 1.332, 1.139, 0.99, 0.623, -0.464, -0.444, -0.937, -1.257, -0.309, -0.191,
653 ];
654 const P7: [f64; 100] = [
655 -0.405, 1.024, 2.014, 1.316, -0.877, -2.408, -1.315, 0.387, 2.192, 1.706, -0.547, -0.904,
656 -2.012, -1.411, 0.729, 1.961, 2.296, 0.007, -1.402, -2.969, -0.806, 0.026, 2.031, 1.991,
657 0.952, -0.252, -2.209, -2.173, -0.284, 1.642, 2.091, 1.239, -1.528, -2.441, -2.426, 1.019,
658 2.494, 1.57, 1.408, -0.864, -1.606, -0.917, -0.686, 1.56, 2.092, 1.082, -0.255, -1.492,
659 -1.347, -0.327, 1.32, 2.703, 0.523, -2.251, -1.55, -1.012, -0.555, 1.509, 0.575, 1.387,
660 -1.439, -1.585, -1.999, 0.493, 1.583, 3.155, 0.222, -0.852, -0.946, -1.859, 0.365, 0.714,
661 1.485, 2.541, -0.938, -2.451, -0.513, -0.993, 1.238, 1.649, 1.857, -0.15, -2.286, -1.927,
662 0.366, 1.54, 2.751, 0.67, -1.793, -1.811, -2.868, -0.376, 1.697, 2.691, 1.455, -0.128,
663 -1.157, -1.959, -0.437, 1.787,
664 ];
665 const P24: [f64; 240] = [
666 -0.028, 0.609, 1.787, 3.199, 2.769, 2.915, 2.48, 3.235, 4.652, 2.793, 2.392, 0.66, 0.923,
667 0.008, -0.341, -0.667, -1.32, -2.107, -2.182, -2.668, -1.531, -1.029, -0.357, 0.924, 1.311,
668 1.784, 2.404, 3.154, 3.956, 4.717, 4.096, 5.754, 4.309, 3.522, 3.076, 2.995, 1.66, 0.922,
669 0.645, -0.043, -0.087, -0.652, -0.581, -0.576, 0.028, -0.035, 1.385, 1.435, 2.499, 3.093,
670 3.761, 4.778, 5.178, 5.009, 5.745, 5.81, 6.222, 4.029, 3.829, 3.966, 2.815, 1.903, 2.21,
671 1.727, 0.01, -0.152, -0.285, 0.326, -0.05, 1.523, 1.86, 2.699, 4.633, 3.971, 4.718, 5.651,
672 6.12, 7.712, 7.003, 7.008, 7.167, 6.163, 5.666, 4.697, 3.391, 3.98, 2.201, 2.345, 1.461,
673 2.003, 1.194, 1.89, 2.753, 2.393, 3.207, 3.877, 4.394, 6.182, 7.156, 7.105, 7.659, 7.796,
674 6.801, 7.941, 7.674, 7.449, 6.244, 5.446, 6.2, 3.994, 4.43, 3.946, 3.43, 2.693, 2.847,
675 1.732, 3.037, 3.464, 4.177, 3.99, 5.991, 6.142, 7.402, 8.317, 8.725, 8.623, 9.919, 9.348,
676 8.86, 8.834, 8.686, 8.241, 6.759, 5.253, 5.143, 4.807, 3.718, 3.204, 4.262, 3.956, 4.669,
677 4.538, 5.857, 6.221, 6.643, 7.757, 9.384, 10.244, 9.055, 10.805, 10.256, 10.614, 10.293,
678 10.565, 10.015, 9.31, 7.56, 6.553, 6.546, 5.058, 5.211, 5.108, 5.024, 5.025, 5.484, 6.953,
679 7.197, 7.658, 8.36, 10.328, 10.93, 11.583, 11.354, 12.083, 12.908, 12.226, 11.578, 11.467,
680 9.58, 9.983, 8.632, 7.656, 7.346, 7.162, 6.621, 5.586, 6.699, 5.726, 6.188, 6.726, 7.194,
681 9.107, 10.245, 9.727, 11.195, 11.672, 12.642, 13.356, 13.334, 12.319, 11.958, 11.449,
682 11.538, 11.463, 10.018, 9.416, 9.472, 8.601, 7.43, 7.247, 7.852, 7.077, 7.99, 9.097, 8.928,
683 9.882, 11.225, 11.207, 12.784, 13.327, 13.394, 13.874, 14.363, 13.898, 13.256, 12.659,
684 13.573, 12.496, 12.22, 10.514, 9.669, 9.548, 9.926, 8.647, 8.947, 9.164, 8.904, 10.123,
685 10.426, 11.634,
686 ];
687 const NOISE: [f64; 120] = [
688 0.511, 1.186, -0.731, -0.094, 0.062, -0.891, 1.251, -1.361, 0.273, -0.62, -0.567, 0.373,
689 1.054, 1.262, 1.618, -1.328, -0.023, 0.04, 0.273, 0.835, -1.165, -0.103, 1.077, 0.782,
690 0.185, -0.316, -0.429, -1.648, 0.497, 0.477, 1.667, -0.206, -0.936, 0.578, 0.02, 1.225,
691 0.542, -0.442, -0.157, 0.397, 0.58, 0.897, 1.913, 0.751, 0.478, -0.977, -0.226, 0.75,
692 -1.707, 0.961, -0.863, 0.807, 0.594, 0.15, -0.712, -0.87, 1.278, -1.761, -0.247, 0.622,
693 -1.098, -0.95, -2.189, 0.41, -0.227, 0.848, 0.332, -1.668, -1.059, 0.356, 0.257, -0.343,
694 1.902, 0.674, 2.441, 0.564, 1.348, -0.64, 0.909, -0.767, -1.069, 1.354, 0.339, -0.123,
695 0.663, 1.073, -1.47, 0.433, -0.865, -1.236, 0.53, -0.353, -0.699, -0.434, 1.476, 0.381,
696 0.302, 1.587, -0.238, 3.151, -0.864, 1.442, 2.826, -0.295, -0.208, 1.768, -1.3, 0.474,
697 0.368, 0.265, 0.209, 0.803, 0.38, -0.744, 0.217, 0.417, 1.981, 0.881, 0.286, -1.222,
698 ];
699
700 #[test]
701 fn the_periodogram_reproduces_numpy() {
702 let pg = periodogram(&PG_SERIES).expect("a valid series");
703 assert_eq!(pg.padded_len, 128);
704 assert_eq!(pg.power.len(), PG_POWER.len());
705 let scale = PG_POWER.iter().cloned().fold(0.0, f64::max);
706 for (k, (got, want)) in pg.power.iter().zip(PG_POWER).enumerate() {
707 assert!(
708 (got - want).abs() <= 1e-9 * scale,
709 "bin {}: got {got}, NumPy {want}",
710 k + 1
711 );
712 }
713 }
714
715 #[test]
716 fn the_dominant_period_is_the_one_the_series_was_made_with() {
717 for (series, want, what) in [
718 (&P12[..], Some(12), "period 12"),
719 (&P7[..], Some(7), "period 7 in 100 points"),
720 (&P24[..], Some(24), "period 24 on a trend"),
721 (&NOISE[..], None, "white noise"),
722 ] {
723 let found = estimate_period(series).expect("a valid series");
724 assert_eq!(found.period, want, "{what}");
725 }
726 }
727}