use crate::config::SpotConfig;
use crate::error::{SpotError, SpotResult};
use crate::p2::p2_quantile;
use crate::status::SpotStatus;
use crate::tail::Tail;
#[derive(Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SpotDetector {
q: f64,
level: f64,
discard_anomalies: bool,
low: bool,
up_down: f64,
#[cfg_attr(feature = "serde", serde(with = "crate::ser::nan_safe_f64"))]
anomaly_threshold: f64,
#[cfg_attr(feature = "serde", serde(with = "crate::ser::nan_safe_f64"))]
excess_threshold: f64,
nt: usize,
n: usize,
tail: Tail,
}
impl SpotDetector {
pub fn new(config: SpotConfig) -> SpotResult<Self> {
if config.level < 0.0 || config.level >= 1.0 {
return Err(SpotError::LevelOutOfBounds);
}
if config.q >= (1.0 - config.level) || config.q <= 0.0 {
return Err(SpotError::QOutOfBounds);
}
let up_down = if config.low_tail { -1.0 } else { 1.0 };
Ok(Self {
q: config.q,
level: config.level,
discard_anomalies: config.discard_anomalies,
low: config.low_tail,
up_down,
anomaly_threshold: f64::NAN,
excess_threshold: f64::NAN,
nt: 0,
n: 0,
tail: Tail::new(config.max_excess)?,
})
}
pub fn fit(&mut self, data: &[f64]) -> SpotResult<()> {
self.nt = 0;
self.n = data.len();
let et = if self.low {
p2_quantile(1.0 - self.level, data)
} else {
p2_quantile(self.level, data)
};
if et.is_nan() {
return Err(SpotError::ExcessThresholdIsNaN);
}
self.excess_threshold = et;
for &value in data {
let excess = self.up_down * (value - et);
if excess > 0.0 {
self.nt += 1;
self.tail.push(excess);
}
}
self.tail.fit();
self.anomaly_threshold = self.quantile(self.q);
if self.anomaly_threshold.is_nan() {
return Err(SpotError::AnomalyThresholdIsNaN);
}
Ok(())
}
pub fn step(&mut self, value: f64) -> SpotResult<SpotStatus> {
if value.is_nan() {
return Err(SpotError::DataIsNaN);
}
if self.discard_anomalies && (self.up_down * (value - self.anomaly_threshold) > 0.0) {
return Ok(SpotStatus::Anomaly);
}
self.n += 1;
let ex = self.up_down * (value - self.excess_threshold);
if ex >= 0.0 {
self.nt += 1;
self.tail.push(ex);
self.tail.fit();
self.anomaly_threshold = self.quantile(self.q);
return Ok(SpotStatus::Excess);
}
Ok(SpotStatus::Normal)
}
pub fn quantile(&self, q: f64) -> f64 {
if self.n == 0 {
return f64::NAN;
}
let s = (self.nt as f64) / (self.n as f64);
self.excess_threshold + self.up_down * self.tail.quantile(s, q)
}
pub fn probability(&self, z: f64) -> f64 {
if self.n == 0 {
return f64::NAN;
}
let s = (self.nt as f64) / (self.n as f64);
self.tail
.probability(s, self.up_down * (z - self.excess_threshold))
}
pub fn anomaly_threshold(&self) -> f64 {
self.anomaly_threshold
}
pub fn excess_threshold(&self) -> f64 {
self.excess_threshold
}
pub fn config(&self) -> Option<SpotConfig> {
Some(SpotConfig {
q: self.q,
low_tail: self.low,
discard_anomalies: self.discard_anomalies,
level: self.level,
max_excess: self.tail.peaks().container().capacity(),
})
}
pub fn n(&self) -> usize {
self.n
}
pub fn nt(&self) -> usize {
self.nt
}
pub fn tail_parameters(&self) -> (f64, f64) {
(self.tail.gamma(), self.tail.sigma())
}
pub fn tail_size(&self) -> usize {
self.tail.size()
}
pub fn peaks_min(&self) -> f64 {
self.tail.peaks().min()
}
pub fn peaks_max(&self) -> f64 {
self.tail.peaks().max()
}
pub fn peaks_mean(&self) -> f64 {
self.tail.peaks().mean()
}
pub fn peaks_variance(&self) -> f64 {
self.tail.peaks().variance()
}
pub fn peaks_data(&self) -> Vec<f64> {
self.tail.peaks().container().data()
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn test_spot_creation_valid_config() {
let config = SpotConfig::default();
let spot = SpotDetector::new(config).unwrap();
assert_relative_eq!(spot.q, 0.0001);
assert!(!spot.low);
assert!(spot.discard_anomalies);
assert_relative_eq!(spot.level, 0.998);
assert!(spot.anomaly_threshold().is_nan());
assert!(spot.excess_threshold().is_nan());
assert_eq!(spot.n(), 0);
assert_eq!(spot.nt(), 0);
}
#[test]
fn test_spot_invalid_level() {
let config = SpotConfig {
level: 1.5, ..SpotConfig::default()
};
let result = SpotDetector::new(config);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), SpotError::LevelOutOfBounds);
}
#[test]
fn test_spot_invalid_q() {
let config = SpotConfig {
q: 0.5, ..SpotConfig::default()
};
let result = SpotDetector::new(config);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), SpotError::QOutOfBounds);
}
#[test]
fn test_spot_fit_basic() {
let config = SpotConfig::default();
let mut spot = SpotDetector::new(config).unwrap();
let data: Vec<f64> = (0..1000).map(|i| (i as f64 / 1000.0) * 2.0 - 1.0).collect();
let result = spot.fit(&data);
assert!(result.is_ok());
assert!(!spot.anomaly_threshold().is_nan());
assert!(!spot.excess_threshold().is_nan());
assert!(spot.anomaly_threshold().is_finite());
assert!(spot.excess_threshold().is_finite());
assert_eq!(spot.n(), 1000);
assert!(spot.nt() > 0); }
#[test]
fn test_spot_step_normal() {
let config = SpotConfig::default();
let mut spot = SpotDetector::new(config).unwrap();
let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
spot.fit(&data).unwrap();
let result = spot.step(50.0);
assert!(result.is_ok());
}
#[test]
fn test_spot_step_nan() {
let config = SpotConfig::default();
let mut spot = SpotDetector::new(config).unwrap();
let result = spot.step(f64::NAN);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), SpotError::DataIsNaN);
}
#[test]
fn test_spot_low_tail() {
let config = SpotConfig {
low_tail: true,
..SpotConfig::default()
};
let spot = SpotDetector::new(config).unwrap();
assert!(spot.low);
assert_relative_eq!(spot.up_down, -1.0);
}
#[test]
fn test_spot_config_roundtrip() {
let original_config = SpotConfig {
q: 0.001,
low_tail: true,
discard_anomalies: false,
level: 0.99,
max_excess: 100,
};
let spot = SpotDetector::new(original_config.clone()).unwrap();
let retrieved_config = spot.config().unwrap();
assert_relative_eq!(retrieved_config.q, original_config.q);
assert_eq!(retrieved_config.low_tail, original_config.low_tail);
assert_eq!(
retrieved_config.discard_anomalies,
original_config.discard_anomalies
);
assert_relative_eq!(retrieved_config.level, original_config.level);
assert_eq!(retrieved_config.max_excess, original_config.max_excess);
}
#[test]
fn test_spot_quantile_probability_consistency() {
let config = SpotConfig::default();
let mut spot = SpotDetector::new(config).unwrap();
let data: Vec<f64> = (1..=100).map(|i| i as f64).collect();
spot.fit(&data).unwrap();
let q = spot.quantile(0.01);
assert!(!q.is_nan());
assert!(q.is_finite());
let p = spot.probability(q);
assert!(!p.is_nan());
assert!(p >= 0.0);
}
#[test]
fn test_spot_excess_detection() {
let config = SpotConfig {
level: 0.9, ..SpotConfig::default()
};
let mut spot = SpotDetector::new(config).unwrap();
let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
spot.fit(&data).unwrap();
let _initial_nt = spot.nt();
let result = spot.step(95.0);
assert!(result.is_ok());
match result.unwrap() {
SpotStatus::Normal | SpotStatus::Excess | SpotStatus::Anomaly => {
}
}
}
}