Skip to main content

u_analytics/
seasonality.rs

1//! Seasonality: the periodogram of a univariate series and the estimation of
2//! its dominant period.
3//!
4//! Smoothing with [`crate::smoothing::HoltWinters`] and any seasonal
5//! decomposition need the period as an input. This module estimates it from
6//! the data by the two-stage procedure of AutoPeriod (Vlachos, Yu & Castelli
7//! 2005):
8//!
9//! 1. **Periodogram** — the series is linearly detrended, zero-padded to a
10//!    power of two at least four times its length, and transformed with the
11//!    FFT; the power at each frequency bin is `|X_k|² / n`. Padding
12//!    interpolates the spectrum so that a period which does not divide the
13//!    length still shows one peak at its true frequency rather than two
14//!    half-height neighbours. A local maximum is a candidate when its power
15//!    exceeds what the same
16//!    values in random order produce: the series is permuted 100 times and
17//!    the 99th percentile of the largest periodogram ordinate is the
18//!    threshold (AutoPeriod's permutation test, deterministic here). A
19//!    periodogram bin names a *frequency band*, not an integer period: bin
20//!    `k` of a padded length `m` covers periods between `m/(k+1)` and
21//!    `m/(k−1)`.
22//! 2. **Autocorrelation refinement** — within each candidate's band the
23//!    autocorrelation function is searched for its hill top: the lag with
24//!    the largest ACF that is a local maximum and, after correcting the
25//!    biased estimator's `(n − lag)/n` shrinkage, exceeds the 95% white-noise
26//!    bound `1.96/√n`. That lag is the integer period. A candidate whose band
27//!    has no such hill is discarded — it was spectral leakage or a harmonic
28//!    without its own structure.
29//!
30//! The dominant period is the validated candidate with the largest ACF; every
31//! validated candidate is returned so a caller can see harmonics and
32//! competing cycles. The ACF is the biased estimator (see
33//! [`crate::correlation::acf`]), which slightly favours shorter lags — the
34//! fundamental over its multiples.
35//!
36//! # References
37//! - Vlachos, M., Yu, P. & Castelli, V. (2005). "On periodicity detection and
38//!   structural periodic similarity." *SIAM International Conference on Data
39//!   Mining*, 449–460.
40//! - Box, G. E. P. & Jenkins, G. M. (1976). *Time Series Analysis: Forecasting
41//!   and Control*, ch. 2 (autocorrelation).
42
43use u_numflow::fourier::rfft;
44use u_numflow::random::{create_rng, shuffle};
45
46use crate::correlation::acf;
47
48/// Fewest observations for which a period is estimated: two cycles of the
49/// longest admissible period (`n/2`) with a few bins to test against.
50pub const MIN_OBSERVATIONS: usize = 8;
51
52/// Permutations used to set the periodogram threshold.
53const PERMUTATIONS: usize = 100;
54
55/// The threshold is this quantile of the largest ordinate over permutations.
56const PERMUTATION_QUANTILE: f64 = 0.99;
57
58/// Seed of the permutation stream: the estimate is a deterministic function
59/// of the series.
60const PERMUTATION_SEED: u64 = 0x5EA5_0A11;
61
62/// How many periodogram peaks are carried into the autocorrelation stage.
63const MAX_CANDIDATES: usize = 3;
64
65/// Periodogram of a linearly detrended, zero-padded series.
66#[derive(Debug, Clone, PartialEq)]
67pub struct Periodogram {
68    /// Number of observations.
69    pub n: usize,
70    /// Padded transform length: the power of two at least `4 n`.
71    pub padded_len: usize,
72    /// Power `|X_k|² / n` at bins `k = 1..=padded_len/2`; `power[i]` is bin
73    /// `i + 1`.
74    pub power: Vec<f64>,
75    /// Frequency of each bin in cycles per observation, `k / padded_len`.
76    pub frequency: Vec<f64>,
77}
78
79impl Periodogram {
80    /// Period, in observations, of bin `k` (1-based): `padded_len / k`.
81    pub fn period_of_bin(&self, k: usize) -> f64 {
82        self.padded_len as f64 / k as f64
83    }
84
85    /// Bin (1-based) of the largest ordinate, or `None` for an all-zero
86    /// periodogram.
87    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/// One periodicity that passed both stages.
98#[derive(Debug, Clone, PartialEq)]
99pub struct PeriodCandidate {
100    /// Integer period, in observations.
101    pub period: usize,
102    /// Autocorrelation at that lag, with the `(n − lag)/n` bias of the
103    /// estimator undone — the same value this candidate was selected and
104    /// accepted on, so it is comparable against
105    /// [`SeasonalityResult::acf_threshold`] and against other candidates.
106    pub acf: f64,
107    /// Periodogram bin (1-based) whose band produced this candidate.
108    pub bin: usize,
109    /// Periodogram power of that bin.
110    pub power: f64,
111    /// That bin's share of the total periodogram power.
112    pub power_share: f64,
113}
114
115/// Result of period estimation.
116#[derive(Debug, Clone, PartialEq)]
117pub struct SeasonalityResult {
118    /// The dominant period, or `None` when no periodicity passed both
119    /// stages — a constant, a pure trend, white noise.
120    pub period: Option<usize>,
121    /// Every validated periodicity, strongest first. Empty when `period` is
122    /// `None`.
123    pub candidates: Vec<PeriodCandidate>,
124    /// Number of observations.
125    pub n: usize,
126    /// The 95% white-noise bound on the ACF, `1.96 / √n`.
127    pub acf_threshold: f64,
128    /// Periodogram power a bin had to exceed to become a candidate: the 99th
129    /// percentile of the largest ordinate over 100 permutations of the series.
130    pub power_threshold: f64,
131}
132
133/// Periodogram of the series after linear detrending.
134///
135/// Returns `None` when the series has fewer than [`MIN_OBSERVATIONS`] points
136/// or a non-finite value. A constant series returns a periodogram of zeros.
137///
138/// # Examples
139///
140/// ```
141/// use u_analytics::seasonality::periodogram;
142///
143/// let series: Vec<f64> = (0..48).map(|t| (2.0 * std::f64::consts::PI * t as f64 / 12.0).sin()).collect();
144/// let p = periodogram(&series).unwrap();
145/// // Padded to 256: bin 256 / 12 ≈ 21.3, so the peak sits at bin 21.
146/// let peak = p.peak_bin().unwrap();
147/// assert_eq!(peak, 21);
148/// assert!((p.period_of_bin(peak) - 12.0).abs() < 0.3);
149/// ```
150pub 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
169/// Estimate the dominant period of the series.
170///
171/// Returns `None` when the input cannot be analysed (fewer than
172/// [`MIN_OBSERVATIONS`] points, or a non-finite value). Otherwise the result
173/// says explicitly whether a period was found: [`SeasonalityResult::period`]
174/// is `None` for a series with no periodicity that survives both the
175/// periodogram and the autocorrelation stage.
176///
177/// Only periods from 2 to `n / 2` are admissible — anything longer has not
178/// been observed twice.
179///
180/// # Examples
181///
182/// ```
183/// use u_analytics::seasonality::estimate_period;
184///
185/// // A sawtooth of period 7 over 40 observations.
186/// let series: Vec<f64> = (0..40).map(|i| (i % 7) as f64).collect();
187/// let result = estimate_period(&series).unwrap();
188/// assert_eq!(result.period, Some(7));
189///
190/// // A straight line has no period.
191/// let line: Vec<f64> = (0..40).map(|i| 2.0 * i as f64).collect();
192/// assert_eq!(estimate_period(&line).unwrap().period, None);
193/// ```
194pub 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    // Stage 1: local maxima of the periodogram above the permutation
217    // threshold, strongest first, at most MAX_CANDIDATES.
218    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    // Stage 2: the ACF hill inside each candidate's frequency band.
237    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        // The biased ACF shrinks by (n − lag)/n, and that shrinkage is
255        // monotone in the lag -- so on a hill spanning several lags it tilts
256        // every comparison toward the shorter one, and the estimate lands
257        // below the true period whenever the series is not a whole number of
258        // cycles long. Undoing the bias is exactly what removes that tilt, so
259        // the corrected value decides all three questions here: which lag in
260        // the band, whether it is a hill top, and whether it clears the bound.
261        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            // Two bins straddling one period: keep the stronger bin's numbers.
278            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
305/// Residuals of the least-squares line through the series.
306fn 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
324/// Transform length: the power of two at least four times `n`.
325fn padded_len(n: usize) -> usize {
326    (4 * n).next_power_of_two()
327}
328
329/// `|X_k|² / n` for bins `1..=m/2` of the series zero-padded to length `m`.
330fn 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
340/// Largest periodogram ordinate the values produce in random order, at the
341/// 99th percentile over 100 seeded permutations. Power above this is
342/// structure the ordering carries, not the marginal distribution.
343fn 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    /// Deterministic uniform noise in [-1, 1) from a linear congruential
367    /// generator, so the tests do not depend on a random crate.
368    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        // The case a well-known library answers with "none": 40 rows of i % 7.
383        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        // The permutation threshold has a 1% false-positive rate per series;
431        // with fixed seeds the answer is a fixed fact.
432        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                    // Eight or twelve points rarely beat the permutation
443                    // threshold: too few orderings differ from the observed.
444                    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    /// Reported by a consumer: a 63-point period over 300 observations --
467    /// 4.76 cycles -- came back as 60.
468    ///
469    /// The estimate has to hold when the series is not a whole number of
470    /// cycles long, which is the ordinary case for real data. The sweep below
471    /// is the general statement; this is the series as reported.
472    #[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        // The same series at whole cycles was never in doubt; the two together
489        // say the fractional tail is what used to move the answer.
490        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    /// A candidate's `acf` is the value it was judged on, so a consumer
508    /// comparing it against `acf_threshold` reaches the crate's own verdict.
509    #[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        // The mainlobe peak carries the sine's energy: n/4 · amplitude² = 12
550        // before detrending, a little less after it.
551        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/// The periodogram against NumPy (linear detrend by least squares, zero padding
560/// to the power of two at least 4 n, `|rfft|^2 / n` at bins 1..=m/2), and the
561/// dominant period against series generated with a known one -- a sine of
562/// period 12, one of period 7 that does not divide the length of 100, one of
563/// period 24 on a trend -- and against pure noise, which has none. Seeded
564/// NumPy generator.
565#[cfg(test)]
566// The generated samples are data; one of them happens to be near 1/pi.
567#[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}