use crate::core::TimeSeries;
use crate::features::{
approximate_entropy, autocorrelation, kurtosis, lempel_ziv_complexity, linear_trend, maximum,
mean, minimum, partial_autocorrelation, skewness, standard_deviation, LinearTrendResult,
};
use crate::validation::stationarity::{adf_test, kpss_test};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrendDirection {
Rising,
Falling,
Flat,
}
impl fmt::Display for TrendDirection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TrendDirection::Rising => write!(f, "Rising"),
TrendDirection::Falling => write!(f, "Falling"),
TrendDirection::Flat => write!(f, "Flat"),
}
}
}
#[derive(Debug, Clone)]
pub struct DataProfile {
pub n_observations: usize,
pub mean: f64,
pub std_dev: f64,
pub min: f64,
pub max: f64,
pub missing_count: usize,
pub missing_fraction: f64,
pub has_negatives: bool,
pub has_zeros: bool,
pub is_integer: bool,
pub adf_statistic: f64,
pub adf_p_value: f64,
pub adf_is_stationary: bool,
pub kpss_statistic: f64,
pub kpss_p_value: f64,
pub kpss_is_stationary: bool,
pub trend_strength: f64,
pub trend_slope: f64,
pub trend_direction: TrendDirection,
pub acf_lag1: f64,
pub acf_lag2: f64,
pub partial_acf_lag1: f64,
pub skewness: f64,
pub kurtosis: f64,
pub approximate_entropy: Option<f64>,
pub lempel_ziv: f64,
pub zero_fraction: f64,
pub is_intermittent: bool,
pub quality_score: f64,
}
impl DataProfile {
pub fn from_series(ts: &TimeSeries) -> Self {
let values: &[f64] = ts.values(0).unwrap_or(&[]);
Self::from_values(values)
}
pub fn from_values(values: &[f64]) -> Self {
let n_observations = values.len();
let missing_count = values
.iter()
.filter(|v| v.is_nan() || v.is_infinite())
.count();
let missing_fraction = if n_observations > 0 {
missing_count as f64 / n_observations as f64
} else {
0.0
};
let clean: Vec<f64> = values.iter().copied().filter(|v| v.is_finite()).collect();
let has_negatives = clean.iter().any(|&v| v < 0.0);
let has_zeros = clean.contains(&0.0);
let is_integer = clean.iter().all(|&v| v == v.floor());
let mn = mean(&clean);
let std_dev = standard_deviation(&clean);
let min_val = if clean.is_empty() {
f64::NAN
} else {
minimum(&clean)
};
let max_val = if clean.is_empty() {
f64::NAN
} else {
maximum(&clean)
};
let adf = adf_test(&clean, None);
let kpss = kpss_test(&clean, None);
let trend: LinearTrendResult = linear_trend(&clean);
let trend_strength = if trend.r_squared.is_nan() {
0.0
} else {
trend.r_squared.clamp(0.0, 1.0)
};
let trend_slope = if trend.slope.is_nan() {
0.0
} else {
trend.slope
};
let trend_direction = classify_trend(trend_slope, std_dev);
let acf_lag1 = autocorrelation(&clean, 1);
let acf_lag2 = autocorrelation(&clean, 2);
let partial_acf_lag1 = partial_autocorrelation(&clean, 1);
let skew = skewness(&clean);
let kurt = kurtosis(&clean);
let apen = {
let r = 0.2 * std_dev;
let val = approximate_entropy(&clean, 2, r);
if val.is_nan() {
None
} else {
Some(val)
}
};
let lz = lempel_ziv_complexity(&clean, 10);
let zero_count = clean.iter().filter(|&&v| v == 0.0).count();
let zero_fraction = if clean.is_empty() {
0.0
} else {
zero_count as f64 / clean.len() as f64
};
let is_intermittent = zero_fraction > 0.1;
let has_outliers = if std_dev > 0.0 && !std_dev.is_nan() {
clean.iter().any(|&v| ((v - mn) / std_dev).abs() > 4.0)
} else {
false
};
let quality_score =
(1.0 - missing_fraction - if has_outliers { 0.1 } else { 0.0 }).clamp(0.0, 1.0);
let safe = |v: f64| if v.is_nan() { 0.0 } else { v };
DataProfile {
n_observations,
mean: mn,
std_dev,
min: min_val,
max: max_val,
missing_count,
missing_fraction,
has_negatives,
has_zeros,
is_integer,
adf_statistic: adf.statistic,
adf_p_value: adf.p_value,
adf_is_stationary: adf.is_stationary,
kpss_statistic: kpss.statistic,
kpss_p_value: kpss.p_value,
kpss_is_stationary: kpss.is_stationary,
trend_strength,
trend_slope,
trend_direction,
acf_lag1: safe(acf_lag1),
acf_lag2: safe(acf_lag2),
partial_acf_lag1: safe(partial_acf_lag1),
skewness: safe(skew),
kurtosis: safe(kurt),
approximate_entropy: apen,
lempel_ziv: lz,
zero_fraction,
is_intermittent,
quality_score,
}
}
pub fn is_stationary(&self) -> bool {
let adf_nan = self.adf_statistic.is_nan();
let kpss_nan = self.kpss_statistic.is_nan();
if adf_nan && kpss_nan {
false
} else if adf_nan {
self.kpss_is_stationary
} else if kpss_nan {
self.adf_is_stationary
} else {
self.adf_is_stationary && self.kpss_is_stationary
}
}
pub fn summary(&self) -> String {
let mut s = String::new();
s.push_str(&format!(
"DataProfile ({} observations)\n",
self.n_observations
));
s.push_str(&format!(
" Basic: mean={:.4}, std={:.4}, min={:.4}, max={:.4}\n",
self.mean, self.std_dev, self.min, self.max
));
s.push_str(&format!(
" Quality: missing={} ({:.1}%), quality_score={:.2}\n",
self.missing_count,
self.missing_fraction * 100.0,
self.quality_score,
));
s.push_str(&format!(
" Flags: negatives={}, zeros={}, integer={}, intermittent={}\n",
self.has_negatives, self.has_zeros, self.is_integer, self.is_intermittent,
));
s.push_str(&format!(
" Trend: direction={}, slope={:.6}, strength={:.4}\n",
self.trend_direction, self.trend_slope, self.trend_strength,
));
s.push_str(&format!(
" Stationarity: ADF(stat={:.4}, p={:.4}, stationary={}), KPSS(stat={:.4}, p={:.4}, stationary={})\n",
self.adf_statistic, self.adf_p_value, self.adf_is_stationary,
self.kpss_statistic, self.kpss_p_value, self.kpss_is_stationary,
));
s.push_str(&format!(
" ACF: lag1={:.4}, lag2={:.4}, PACF lag1={:.4}\n",
self.acf_lag1, self.acf_lag2, self.partial_acf_lag1,
));
s.push_str(&format!(
" Distribution: skewness={:.4}, kurtosis={:.4}\n",
self.skewness, self.kurtosis,
));
s.push_str(&format!(
" Complexity: approx_entropy={}, lempel_ziv={:.4}\n",
match self.approximate_entropy {
Some(v) => format!("{:.4}", v),
None => "N/A".to_string(),
},
self.lempel_ziv,
));
s
}
}
impl fmt::Display for DataProfile {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.summary())
}
}
fn classify_trend(slope: f64, std_dev: f64) -> TrendDirection {
let threshold = 0.001
* if std_dev.is_nan() || std_dev == 0.0 {
1.0
} else {
std_dev
};
if slope.abs() < threshold {
TrendDirection::Flat
} else if slope > 0.0 {
TrendDirection::Rising
} else {
TrendDirection::Falling
}
}
#[cfg(test)]
mod tests {
use super::*;
fn profile_from(values: &[f64]) -> DataProfile {
DataProfile::from_values(values)
}
#[test]
fn profile_constant_series() {
let series = vec![5.0; 100];
let p = profile_from(&series);
assert_eq!(p.n_observations, 100);
assert!((p.mean - 5.0).abs() < 1e-10);
assert!((p.std_dev).abs() < 1e-10);
assert_eq!(p.trend_direction, TrendDirection::Flat);
assert!((p.trend_slope).abs() < 1e-10);
assert!((p.acf_lag1).abs() < 1e-10);
if let Some(apen) = p.approximate_entropy {
assert!(
apen.abs() < 0.5,
"constant series entropy should be near zero, got {}",
apen
);
}
}
#[test]
fn profile_trending_series() {
let series: Vec<f64> = (0..200)
.map(|i| i as f64 * 2.0 + ((i * 13) % 7) as f64 * 0.01)
.collect();
let p = profile_from(&series);
assert_eq!(p.trend_direction, TrendDirection::Rising);
assert!(
p.trend_slope > 1.0,
"expected positive slope, got {}",
p.trend_slope
);
assert!(
p.trend_strength > 0.9,
"expected high R^2, got {}",
p.trend_strength
);
}
#[test]
fn profile_with_negatives() {
let series = vec![-3.0, -1.0, 0.0, 2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0];
let p = profile_from(&series);
assert!(p.has_negatives, "should detect negative values");
assert!(p.has_zeros, "should detect zeros");
}
#[test]
fn profile_with_zeros() {
let mut series = vec![0.0; 50];
series.extend(vec![10.0; 50]);
let p = profile_from(&series);
assert!(p.has_zeros, "should detect zeros");
assert!(p.zero_fraction > 0.1);
assert!(p.is_intermittent, "50% zeros should be intermittent");
}
#[test]
fn profile_integer_data() {
let series: Vec<f64> = (0..50).map(|i| i as f64).collect();
let p = profile_from(&series);
assert!(p.is_integer, "integer-valued data should set is_integer");
}
#[test]
fn profile_quality_score_perfect() {
let series: Vec<f64> = (0..100).map(|i| (i as f64 * 0.1).sin()).collect();
let p = profile_from(&series);
assert_eq!(p.missing_count, 0);
assert!((p.missing_fraction).abs() < 1e-10);
assert!(
p.quality_score > 0.85,
"expected high quality score, got {}",
p.quality_score
);
}
#[test]
fn profile_with_missing_values() {
let mut series: Vec<f64> = (0..100).map(|i| i as f64).collect();
for i in 0..10 {
series[i * 10] = f64::NAN;
}
let p = profile_from(&series);
assert_eq!(p.missing_count, 10);
assert!((p.missing_fraction - 0.1).abs() < 1e-10);
assert!(
p.quality_score < 1.0,
"quality should be reduced by missing data"
);
}
#[test]
fn profile_short_series() {
let series = vec![1.0, 2.0, 3.0];
let p = profile_from(&series);
assert_eq!(p.n_observations, 3);
assert!(p.trend_slope > 0.0);
assert!(p.adf_statistic.is_nan() || p.adf_statistic.is_finite());
}
#[test]
fn profile_display() {
let series: Vec<f64> = (0..50).map(|i| i as f64).collect();
let p = profile_from(&series);
let text = p.summary();
assert!(
text.contains("DataProfile"),
"summary should contain header"
);
assert!(text.contains("mean="), "summary should contain mean");
assert!(
text.contains("Trend:"),
"summary should contain trend section"
);
assert!(
text.contains("Stationarity:"),
"summary should contain stationarity"
);
assert!(
text.contains("Complexity:"),
"summary should contain complexity"
);
let display_text = format!("{}", p);
assert_eq!(display_text, text);
}
#[test]
fn profile_is_stationary_combined() {
let series: Vec<f64> = (0..200)
.map(|i| ((i * 17 + 13) % 97) as f64 / 50.0 - 1.0)
.collect();
let p = profile_from(&series);
if !p.adf_statistic.is_nan() && !p.kpss_statistic.is_nan() {
assert_eq!(
p.is_stationary(),
p.adf_is_stationary && p.kpss_is_stationary,
"is_stationary() should combine ADF and KPSS"
);
}
let trending: Vec<f64> = (0..200)
.map(|i| i as f64 * 0.5 + ((i * 13) % 7) as f64 * 0.01)
.collect();
let p2 = profile_from(&trending);
assert!(
!p2.is_stationary() || p2.adf_statistic.is_nan(),
"strong trend should not be stationary"
);
}
#[test]
fn profile_empty_series() {
let p = profile_from(&[]);
assert_eq!(p.n_observations, 0);
assert_eq!(p.missing_count, 0);
assert!((p.missing_fraction).abs() < 1e-10);
assert!(!p.has_negatives);
assert!(!p.has_zeros);
}
#[test]
fn profile_falling_trend() {
let series: Vec<f64> = (0..200).map(|i| 1000.0 - i as f64 * 3.0).collect();
let p = profile_from(&series);
assert_eq!(p.trend_direction, TrendDirection::Falling);
assert!(p.trend_slope < 0.0);
}
#[test]
fn trend_direction_display() {
assert_eq!(format!("{}", TrendDirection::Rising), "Rising");
assert_eq!(format!("{}", TrendDirection::Falling), "Falling");
assert_eq!(format!("{}", TrendDirection::Flat), "Flat");
}
#[test]
fn profile_non_integer_data() {
let series = vec![1.1, 2.2, 3.3, 4.4, 5.5];
let p = profile_from(&series);
assert!(!p.is_integer, "fractional data should not set is_integer");
}
#[test]
fn profile_infinite_values() {
let series = vec![1.0, f64::INFINITY, 3.0, f64::NEG_INFINITY, 5.0];
let p = profile_from(&series);
assert_eq!(p.missing_count, 2, "infinities should count as missing");
assert!((p.missing_fraction - 0.4).abs() < 1e-10);
}
}