use std::collections::VecDeque;
use std::rc::Rc;
use augurs::seasons::{Detector, PeriodogramDetector};
use super::AugursSeasons;
use crate::types::*;
#[derive(Debug, Clone)]
pub struct AugursSeasonsConfig {
pub window: usize,
pub min_points: usize,
pub min_period: Option<u32>,
pub max_period: Option<u32>,
}
impl AugursSeasonsConfig {
#[must_use]
pub fn new(window: usize) -> Self {
Self {
window,
min_points: (window / 2).max(16),
min_period: None,
max_period: None,
}
}
#[must_use]
pub fn with_period_range(mut self, min_period: u32, max_period: u32) -> Self {
self.min_period = Some(min_period);
self.max_period = Some(max_period);
self
}
#[must_use]
pub fn with_min_points(mut self, min_points: usize) -> Self {
self.min_points = min_points;
self
}
}
impl From<usize> for AugursSeasonsConfig {
fn from(window: usize) -> Self {
Self::new(window)
}
}
pub(crate) struct AugursSeasonsNode {
upstream: Rc<dyn Stream<f64>>,
detector: PeriodogramDetector,
window: usize,
min_points: usize,
buffer: VecDeque<f64>,
value: AugursSeasons,
}
impl AugursSeasonsNode {
fn new(upstream: Rc<dyn Stream<f64>>, config: AugursSeasonsConfig) -> Self {
let mut builder = PeriodogramDetector::builder();
if let Some(min_period) = config.min_period {
builder = builder.min_period(min_period);
}
if let Some(max_period) = config.max_period {
builder = builder.max_period(max_period);
}
let window = config.window.max(config.min_points);
Self {
upstream,
detector: builder.build(),
window,
min_points: config.min_points,
buffer: VecDeque::with_capacity(window),
value: AugursSeasons::default(),
}
}
}
#[node(active = [upstream], output = value: AugursSeasons)]
impl MutableNode for AugursSeasonsNode {
fn cycle(&mut self, _state: &mut GraphState) -> anyhow::Result<bool> {
super::push_windowed(&mut self.buffer, self.upstream.peek_value(), self.window);
if self.buffer.len() < self.min_points {
return Ok(false);
}
let data: Vec<f64> = self.buffer.iter().copied().collect();
self.value = AugursSeasons {
periods: self.detector.detect(&data),
};
Ok(true)
}
}
pub trait AugursSeasonsOperators {
#[must_use]
fn augurs_seasons(
self: &Rc<Self>,
config: impl Into<AugursSeasonsConfig>,
) -> Rc<dyn Stream<AugursSeasons>>;
}
impl AugursSeasonsOperators for dyn Stream<f64> {
fn augurs_seasons(
self: &Rc<Self>,
config: impl Into<AugursSeasonsConfig>,
) -> Rc<dyn Stream<AugursSeasons>> {
AugursSeasonsNode::new(self.clone(), config.into()).into_stream()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::*;
use crate::nodes::*;
use std::time::Duration;
#[test]
fn seasons_detects_known_period() {
let series = ticker(Duration::from_secs(1))
.count()
.map(|n| (n as f64 * std::f64::consts::TAU / 12.0).sin());
let seasons = series.augurs_seasons(AugursSeasonsConfig::new(96));
let captured = seasons.clone().collect();
captured
.run(RunMode::HistoricalFrom(NanoTime::ZERO), RunFor::Cycles(96))
.unwrap();
let last = seasons.peek_value();
assert!(
last.periods.iter().any(|&p| (10..=14).contains(&p)),
"expected a period near 12, got {:?}",
last.periods
);
let dominant = last.dominant().unwrap();
assert!((10..=14).contains(&dominant), "dominant was {dominant}");
}
#[test]
fn seasons_window_below_floor_still_emits() {
let series = ticker(Duration::from_secs(1))
.count()
.map(|n| (n as f64 * std::f64::consts::TAU / 6.0).sin());
let seasons = series.augurs_seasons(AugursSeasonsConfig::new(12));
let captured = seasons.clone().collect();
captured
.run(RunMode::HistoricalFrom(NanoTime::ZERO), RunFor::Cycles(40))
.unwrap();
assert!(
!captured.peek_value().is_empty(),
"should emit despite window < floor"
);
}
#[test]
fn seasons_waits_for_min_points() {
let series = ticker(Duration::from_secs(1))
.count()
.map(|n| (n as f64 * std::f64::consts::TAU / 12.0).sin());
let seasons = series.augurs_seasons(AugursSeasonsConfig::new(96).with_min_points(50));
let captured = seasons.clone().collect();
captured
.run(RunMode::HistoricalFrom(NanoTime::ZERO), RunFor::Cycles(20))
.unwrap();
assert!(captured.peek_value().is_empty());
}
}