use crate::error::Result;
#[derive(Debug, Clone)]
pub struct TimeSeriesPoint {
pub timestamp: f64,
pub value: f64,
}
#[derive(Debug, Clone)]
pub struct TimeSeriesAnalysis {
pub trend: TrendAnalysis,
pub seasonality: SeasonalityAnalysis,
pub changepoints: Vec<ChangePoint>,
pub forecasts: Vec<ForecastPoint>,
pub anomalies: Vec<TimeSeriesAnomaly>,
pub statistics: TimeSeriesStatistics,
}
#[derive(Debug, Clone)]
pub struct TrendAnalysis {
pub slope: f64,
pub intercept: f64,
pub r_squared: f64,
pub trend_strength: f64,
pub direction: TrendDirection,
}
#[derive(Debug, Clone)]
pub enum TrendDirection {
Increasing,
Decreasing,
Stable,
Volatile,
}
#[derive(Debug, Clone)]
pub struct SeasonalityAnalysis {
pub detected: bool,
pub period: Option<f64>,
pub strength: f64,
pub seasonal_components: Vec<f64>,
}
#[derive(Debug, Clone)]
pub struct ChangePoint {
pub timestamp: f64,
pub index: usize,
pub significance: f64,
pub change_type: ChangeType,
pub before_value: f64,
pub after_value: f64,
}
#[derive(Debug, Clone)]
pub enum ChangeType {
LevelShift,
TrendChange,
VarianceChange,
}
#[derive(Debug, Clone)]
pub struct ForecastPoint {
pub timestamp: f64,
pub predicted_value: f64,
pub confidence_interval: (f64, f64),
pub uncertainty: f64,
}
#[derive(Debug, Clone)]
pub struct TimeSeriesAnomaly {
pub timestamp: f64,
pub index: usize,
pub value: f64,
pub expected_value: f64,
pub anomaly_score: f64,
pub anomaly_type: AnomalyType,
}
#[derive(Debug, Clone)]
pub enum AnomalyType {
PointAnomaly,
SequentialAnomaly,
SeasonalAnomaly,
}
#[derive(Debug, Clone)]
pub struct TimeSeriesStatistics {
pub autocorrelation: Vec<f64>,
pub partial_autocorrelation: Vec<f64>,
pub stationarity_test: StationarityResult,
pub noise_level: f64,
pub data_quality: DataQuality,
}
#[derive(Debug, Clone)]
pub struct StationarityResult {
pub is_stationary: bool,
pub test_statistic: f64,
pub p_value: f64,
pub differencing_required: usize,
}
#[derive(Debug, Clone)]
pub struct DataQuality {
pub completeness: f64,
pub consistency: f64,
pub regularity: f64,
pub outlier_ratio: f64,
}
pub fn analyze_timeseries(data: &[TimeSeriesPoint]) -> Result<TimeSeriesAnalysis> {
if data.len() < 10 {
return Err(crate::error::BenfError::InsufficientData(data.len()));
}
let values: Vec<f64> = data.iter().map(|p| p.value).collect();
let timestamps: Vec<f64> = data.iter().map(|p| p.timestamp).collect();
Ok(TimeSeriesAnalysis {
trend: analyze_trend(×tamps, &values)?,
seasonality: detect_seasonality(&values)?,
changepoints: detect_changepoints(×tamps, &values)?,
forecasts: generate_forecasts(×tamps, &values, 5)?,
anomalies: detect_timeseries_anomalies(×tamps, &values)?,
statistics: calculate_timeseries_statistics(&values)?,
})
}
fn analyze_trend(timestamps: &[f64], values: &[f64]) -> Result<TrendAnalysis> {
let n = values.len() as f64;
let sum_x: f64 = timestamps.iter().sum();
let sum_y: f64 = values.iter().sum();
let sum_xy: f64 = timestamps
.iter()
.zip(values.iter())
.map(|(x, y)| x * y)
.sum();
let sum_x2: f64 = timestamps.iter().map(|x| x * x).sum();
let slope = (n * sum_xy - sum_x * sum_y) / (n * sum_x2 - sum_x * sum_x);
let intercept = (sum_y - slope * sum_x) / n;
let mean_y = sum_y / n;
let ss_tot: f64 = values.iter().map(|y| (y - mean_y).powi(2)).sum();
let ss_res: f64 = timestamps
.iter()
.zip(values.iter())
.map(|(x, y)| {
let predicted = slope * x + intercept;
(y - predicted).powi(2)
})
.sum();
let r_squared = 1.0 - (ss_res / ss_tot);
let trend_strength = r_squared * slope.abs();
let direction = if slope.abs() < 0.01 {
TrendDirection::Stable
} else if slope > 0.0 {
TrendDirection::Increasing
} else {
TrendDirection::Decreasing
};
Ok(TrendAnalysis {
slope,
intercept,
r_squared,
trend_strength,
direction,
})
}
fn detect_seasonality(values: &[f64]) -> Result<SeasonalityAnalysis> {
let n = values.len();
let mut best_period = None;
let mut best_strength = 0.0;
for period in 2..=(n / 4) {
let strength = calculate_seasonal_strength(values, period);
if strength > best_strength {
best_strength = strength;
best_period = Some(period as f64);
}
}
let detected = best_strength > 0.3; let seasonal_components = if detected {
calculate_seasonal_components(values, best_period.unwrap() as usize)
} else {
vec![0.0; n]
};
Ok(SeasonalityAnalysis {
detected,
period: best_period,
strength: best_strength,
seasonal_components,
})
}
fn detect_changepoints(timestamps: &[f64], values: &[f64]) -> Result<Vec<ChangePoint>> {
let mut changepoints = Vec::new();
let window_size = (values.len() / 10).clamp(5, 20);
for i in window_size..(values.len() - window_size) {
let before_window = &values[(i - window_size)..i];
let after_window = &values[i..(i + window_size)];
let before_mean: f64 = before_window.iter().sum::<f64>() / before_window.len() as f64;
let after_mean: f64 = after_window.iter().sum::<f64>() / after_window.len() as f64;
let before_var: f64 = before_window
.iter()
.map(|x| (x - before_mean).powi(2))
.sum::<f64>()
/ before_window.len() as f64;
let after_var: f64 = after_window
.iter()
.map(|x| (x - after_mean).powi(2))
.sum::<f64>()
/ after_window.len() as f64;
let mean_change = (after_mean - before_mean).abs();
let pooled_std = ((before_var + after_var) / 2.0).sqrt();
if pooled_std > 0.0 {
let significance = mean_change / pooled_std;
if significance > 2.0 {
let change_type = if (after_var / before_var).max(before_var / after_var) > 2.0 {
ChangeType::VarianceChange
} else {
ChangeType::LevelShift
};
changepoints.push(ChangePoint {
timestamp: timestamps[i],
index: i,
significance,
change_type,
before_value: before_mean,
after_value: after_mean,
});
}
}
}
Ok(changepoints)
}
fn generate_forecasts(
timestamps: &[f64],
values: &[f64],
steps: usize,
) -> Result<Vec<ForecastPoint>> {
let trend = analyze_trend(timestamps, values)?;
let last_timestamp = timestamps.last().unwrap();
let time_step = if timestamps.len() > 1 {
(timestamps[timestamps.len() - 1] - timestamps[0]) / (timestamps.len() - 1) as f64
} else {
1.0
};
let mut forecasts = Vec::new();
let residuals: Vec<f64> = timestamps
.iter()
.zip(values.iter())
.map(|(x, y)| {
let predicted = trend.slope * x + trend.intercept;
y - predicted
})
.collect();
let residual_std = {
let mean_residual = residuals.iter().sum::<f64>() / residuals.len() as f64;
let variance = residuals
.iter()
.map(|r| (r - mean_residual).powi(2))
.sum::<f64>()
/ residuals.len() as f64;
variance.sqrt()
};
for i in 1..=steps {
let future_timestamp = last_timestamp + (i as f64 * time_step);
let predicted_value = trend.slope * future_timestamp + trend.intercept;
let uncertainty = residual_std * (1.0 + i as f64 * 0.1); let confidence_interval = (
predicted_value - 1.96 * uncertainty,
predicted_value + 1.96 * uncertainty,
);
forecasts.push(ForecastPoint {
timestamp: future_timestamp,
predicted_value,
confidence_interval,
uncertainty,
});
}
Ok(forecasts)
}
fn detect_timeseries_anomalies(
timestamps: &[f64],
values: &[f64],
) -> Result<Vec<TimeSeriesAnomaly>> {
let mut anomalies = Vec::new();
let window_size = (values.len() / 20).clamp(3, 10);
for i in window_size..(values.len() - window_size) {
let window = &values[(i - window_size)..(i + window_size + 1)];
let mean: f64 = window.iter().sum::<f64>() / window.len() as f64;
let std: f64 = {
let variance =
window.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / window.len() as f64;
variance.sqrt()
};
if std > 0.0 {
let z_score = (values[i] - mean) / std;
if z_score.abs() > 3.0 {
let expected_value = mean;
let anomaly_score = z_score.abs() / 3.0;
anomalies.push(TimeSeriesAnomaly {
timestamp: timestamps[i],
index: i,
value: values[i],
expected_value,
anomaly_score,
anomaly_type: AnomalyType::PointAnomaly,
});
}
}
}
Ok(anomalies)
}
fn calculate_timeseries_statistics(values: &[f64]) -> Result<TimeSeriesStatistics> {
let n = values.len();
let max_lags = (n / 4).min(20);
let mut autocorrelation = Vec::new();
for lag in 0..max_lags {
let correlation = calculate_autocorrelation(values, lag);
autocorrelation.push(correlation);
}
let partial_autocorrelation = autocorrelation.clone();
let stationarity_test = test_stationarity(values);
let noise_level = calculate_noise_level(values);
let data_quality = assess_data_quality(values);
Ok(TimeSeriesStatistics {
autocorrelation,
partial_autocorrelation,
stationarity_test,
noise_level,
data_quality,
})
}
fn calculate_seasonal_strength(values: &[f64], period: usize) -> f64 {
if period >= values.len() {
return 0.0;
}
let mut seasonal_means = vec![0.0; period];
let mut counts = vec![0; period];
for (i, &value) in values.iter().enumerate() {
let season_idx = i % period;
seasonal_means[season_idx] += value;
counts[season_idx] += 1;
}
for (i, &count) in counts.iter().enumerate() {
if count > 0 {
seasonal_means[i] /= count as f64;
}
}
let overall_mean: f64 = seasonal_means.iter().sum::<f64>() / seasonal_means.len() as f64;
let seasonal_variance: f64 = seasonal_means
.iter()
.map(|x| (x - overall_mean).powi(2))
.sum::<f64>()
/ seasonal_means.len() as f64;
let total_variance: f64 = values
.iter()
.map(|x| (x - overall_mean).powi(2))
.sum::<f64>()
/ values.len() as f64;
if total_variance > 0.0 {
seasonal_variance / total_variance
} else {
0.0
}
}
fn calculate_seasonal_components(values: &[f64], period: usize) -> Vec<f64> {
let mut components = vec![0.0; values.len()];
let mut seasonal_means = vec![0.0; period];
let mut counts = vec![0; period];
for (i, &value) in values.iter().enumerate() {
let season_idx = i % period;
seasonal_means[season_idx] += value;
counts[season_idx] += 1;
}
for (i, &count) in counts.iter().enumerate() {
if count > 0 {
seasonal_means[i] /= count as f64;
}
}
for (i, component) in components.iter_mut().enumerate() {
let season_idx = i % period;
*component = seasonal_means[season_idx];
}
components
}
fn calculate_autocorrelation(values: &[f64], lag: usize) -> f64 {
if lag >= values.len() {
return 0.0;
}
let n = values.len() - lag;
let mean: f64 = values.iter().sum::<f64>() / values.len() as f64;
let numerator: f64 = (0..n)
.map(|i| (values[i] - mean) * (values[i + lag] - mean))
.sum();
let denominator: f64 = values.iter().map(|x| (x - mean).powi(2)).sum();
if denominator > 0.0 {
numerator / denominator
} else {
0.0
}
}
fn test_stationarity(values: &[f64]) -> StationarityResult {
let n = values.len();
if n < 3 {
return StationarityResult {
is_stationary: false,
test_statistic: 0.0,
p_value: 1.0,
differencing_required: 1,
};
}
let diff: Vec<f64> = (1..n).map(|i| values[i] - values[i - 1]).collect();
let original_var: f64 = {
let mean = values.iter().sum::<f64>() / values.len() as f64;
values.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / values.len() as f64
};
let diff_var: f64 = {
let mean = diff.iter().sum::<f64>() / diff.len() as f64;
diff.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / diff.len() as f64
};
let test_statistic = if original_var > 0.0 {
diff_var / original_var
} else {
1.0
};
let is_stationary = test_statistic < 0.8;
let p_value = if is_stationary { 0.01 } else { 0.99 };
StationarityResult {
is_stationary,
test_statistic,
p_value,
differencing_required: if is_stationary { 0 } else { 1 },
}
}
fn calculate_noise_level(values: &[f64]) -> f64 {
if values.len() < 3 {
return 0.0;
}
let second_diff: Vec<f64> = (2..values.len())
.map(|i| values[i] - 2.0 * values[i - 1] + values[i - 2])
.collect();
let rms: f64 = second_diff.iter().map(|x| x.powi(2)).sum::<f64>() / second_diff.len() as f64;
rms.sqrt()
}
fn assess_data_quality(values: &[f64]) -> DataQuality {
let n = values.len();
let completeness = 1.0;
let changes: Vec<f64> = (1..n)
.map(|i| ((values[i] - values[i - 1]) / values[i - 1].abs().max(1e-10)).abs())
.collect();
let consistency = 1.0 - (changes.iter().sum::<f64>() / changes.len() as f64).min(1.0);
let regularity = 1.0;
let mean = values.iter().sum::<f64>() / n as f64;
let std = {
let variance = values.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n as f64;
variance.sqrt()
};
let outlier_count = values
.iter()
.filter(|&&x| (x - mean).abs() > 3.0 * std)
.count();
let outlier_ratio = outlier_count as f64 / n as f64;
DataQuality {
completeness,
consistency,
regularity,
outlier_ratio,
}
}
pub fn create_timeseries_from_values(values: &[f64]) -> Vec<TimeSeriesPoint> {
values
.iter()
.enumerate()
.map(|(i, &value)| TimeSeriesPoint {
timestamp: i as f64,
value,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_timeseries_from_values() {
let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let timeseries = create_timeseries_from_values(&values);
assert_eq!(timeseries.len(), 5);
assert_eq!(timeseries[0].value, 1.0);
assert_eq!(timeseries[0].timestamp, 0.0);
assert_eq!(timeseries[4].value, 5.0);
assert_eq!(timeseries[4].timestamp, 4.0);
}
#[test]
fn test_analyze_timeseries_basic() {
let data = vec![
TimeSeriesPoint {
timestamp: 0.0,
value: 1.0,
},
TimeSeriesPoint {
timestamp: 1.0,
value: 2.0,
},
TimeSeriesPoint {
timestamp: 2.0,
value: 3.0,
},
TimeSeriesPoint {
timestamp: 3.0,
value: 4.0,
},
TimeSeriesPoint {
timestamp: 4.0,
value: 5.0,
},
TimeSeriesPoint {
timestamp: 5.0,
value: 6.0,
},
TimeSeriesPoint {
timestamp: 6.0,
value: 7.0,
},
TimeSeriesPoint {
timestamp: 7.0,
value: 8.0,
},
TimeSeriesPoint {
timestamp: 8.0,
value: 9.0,
},
TimeSeriesPoint {
timestamp: 9.0,
value: 10.0,
},
];
let result = analyze_timeseries(&data).unwrap();
assert!(result.trend.slope > 0.0); assert!(result.trend.r_squared > 0.9); matches!(result.trend.direction, TrendDirection::Increasing);
assert_eq!(result.forecasts.len(), 5);
assert!(result.forecasts[0].predicted_value > 10.0);
assert!(!result.statistics.autocorrelation.is_empty());
}
#[test]
fn test_analyze_trend() {
let timestamps = vec![0.0, 1.0, 2.0, 3.0, 4.0];
let values = vec![1.0, 3.0, 5.0, 7.0, 9.0];
let trend = analyze_trend(×tamps, &values).unwrap();
assert!(trend.slope > 1.5); assert!(trend.slope < 2.5);
assert!(trend.r_squared > 0.99); matches!(trend.direction, TrendDirection::Increasing);
}
#[test]
fn test_detect_seasonality() {
let values = vec![
0.0, 1.0, 0.0, -1.0, 0.0, 1.0, 0.0, -1.0, 0.0, 1.0, 0.0, -1.0,
];
let seasonality = detect_seasonality(&values).unwrap();
if seasonality.detected {
assert!(seasonality.period.unwrap() >= 2.0);
assert!(seasonality.strength > 0.0);
}
}
#[test]
fn test_detect_changepoints() {
let timestamps = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0];
let values = vec![1.0, 1.1, 0.9, 1.05, 0.95, 10.0, 9.9, 10.1, 9.95, 10.05];
let changepoints = detect_changepoints(×tamps, &values).unwrap();
if !changepoints.is_empty() {
let major_changepoint = &changepoints[0];
assert!(major_changepoint.index >= 4);
assert!(major_changepoint.index <= 6);
assert!(major_changepoint.significance > 2.0);
}
}
#[test]
fn test_generate_forecasts() {
let timestamps = vec![0.0, 1.0, 2.0, 3.0, 4.0];
let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let forecasts = generate_forecasts(×tamps, &values, 3).unwrap();
assert_eq!(forecasts.len(), 3);
assert!(forecasts[0].predicted_value > 5.0);
assert!(forecasts[1].predicted_value > forecasts[0].predicted_value);
assert!(forecasts[2].predicted_value > forecasts[1].predicted_value);
assert!(forecasts[0].confidence_interval.0 <= forecasts[0].confidence_interval.1);
assert!(forecasts[0].uncertainty >= 0.0); }
#[test]
fn test_detect_timeseries_anomalies() {
let timestamps = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0];
let values = vec![1.0, 2.0, 3.0, 100.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
let anomalies = detect_timeseries_anomalies(×tamps, &values).unwrap();
if !anomalies.is_empty() {
let anomaly = &anomalies[0];
assert!(anomaly.value == 100.0 || anomaly.anomaly_score > 3.0);
matches!(anomaly.anomaly_type, AnomalyType::PointAnomaly);
}
}
#[test]
fn test_calculate_timeseries_statistics() {
let values = vec![1.0, 2.0, 3.0, 4.0, 5.0, 4.0, 3.0, 2.0, 1.0, 2.0];
let stats = calculate_timeseries_statistics(&values).unwrap();
assert!(!stats.autocorrelation.is_empty());
assert!(!stats.partial_autocorrelation.is_empty());
assert!(stats.noise_level >= 0.0);
assert!(stats.data_quality.completeness > 0.0);
assert!(stats.data_quality.consistency >= 0.0);
assert!(stats.data_quality.outlier_ratio >= 0.0);
}
#[test]
fn test_insufficient_data_error() {
let data = vec![
TimeSeriesPoint {
timestamp: 0.0,
value: 1.0,
},
TimeSeriesPoint {
timestamp: 1.0,
value: 2.0,
},
];
let result = analyze_timeseries(&data);
assert!(result.is_err());
}
#[test]
fn test_stable_trend_detection() {
let timestamps = vec![0.0, 1.0, 2.0, 3.0, 4.0];
let values = vec![5.0, 5.01, 4.99, 5.005, 4.995];
let trend = analyze_trend(×tamps, &values).unwrap();
matches!(trend.direction, TrendDirection::Stable);
assert!(trend.slope.abs() < 0.1); }
}