use crate::Timestamp;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum ChangeDirection {
Up,
Down,
}
impl ChangeDirection {
pub fn as_str(&self) -> &'static str {
match self {
ChangeDirection::Up => "up",
ChangeDirection::Down => "down",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct ChangePoint {
pub direction: ChangeDirection,
pub statistic: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Cusum {
target: f64,
slack: f64,
threshold: f64,
c_high: f64,
c_low: f64,
}
impl Cusum {
pub fn new(target: f64, slack: f64, threshold: f64) -> Self {
assert!(slack >= 0.0, "slack must be >= 0");
assert!(threshold >= 0.0, "threshold must be >= 0");
Self {
target,
slack,
threshold,
c_high: 0.0,
c_low: 0.0,
}
}
pub fn observe(&mut self, x: f64) -> Option<ChangePoint> {
self.c_high = (self.c_high + x - (self.target + self.slack)).max(0.0);
self.c_low = (self.c_low + (self.target - self.slack) - x).max(0.0);
if self.c_high > self.threshold {
let statistic = self.c_high - self.threshold;
self.c_high = 0.0;
return Some(ChangePoint {
direction: ChangeDirection::Up,
statistic,
});
}
if self.c_low > self.threshold {
let statistic = self.c_low - self.threshold;
self.c_low = 0.0;
return Some(ChangePoint {
direction: ChangeDirection::Down,
statistic,
});
}
None
}
pub fn sums(&self) -> (f64, f64) {
(self.c_high, self.c_low)
}
pub fn reset(&mut self) {
self.c_high = 0.0;
self.c_low = 0.0;
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PageHinkley {
delta: f64,
lambda: f64,
count: u64,
mean: f64,
m_up: f64,
min_up: f64,
m_down: f64,
max_down: f64,
}
impl PageHinkley {
pub fn new(delta: f64, lambda: f64) -> Self {
assert!(delta >= 0.0, "delta must be >= 0");
assert!(lambda >= 0.0, "lambda must be >= 0");
Self {
delta,
lambda,
count: 0,
mean: 0.0,
m_up: 0.0,
min_up: 0.0,
m_down: 0.0,
max_down: 0.0,
}
}
pub fn observe(&mut self, x: f64) -> Option<ChangePoint> {
self.count += 1;
self.mean += (x - self.mean) / self.count as f64;
self.m_up += x - self.mean - self.delta;
self.min_up = self.min_up.min(self.m_up);
self.m_down += x - self.mean + self.delta;
self.max_down = self.max_down.max(self.m_down);
let ph_up = self.m_up - self.min_up;
let ph_down = self.max_down - self.m_down;
if ph_up > self.lambda {
let statistic = ph_up - self.lambda;
self.reset_accumulators();
return Some(ChangePoint {
direction: ChangeDirection::Up,
statistic,
});
}
if ph_down > self.lambda {
let statistic = ph_down - self.lambda;
self.reset_accumulators();
return Some(ChangePoint {
direction: ChangeDirection::Down,
statistic,
});
}
None
}
pub fn count(&self) -> u64 {
self.count
}
pub fn mean(&self) -> f64 {
self.mean
}
pub fn reset_accumulators(&mut self) {
self.m_up = 0.0;
self.min_up = 0.0;
self.m_down = 0.0;
self.max_down = 0.0;
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct InterArrivalPageHinkley {
inner: PageHinkley,
last: Option<Timestamp>,
}
impl InterArrivalPageHinkley {
pub fn new(delta: f64, lambda: f64) -> Self {
Self {
inner: PageHinkley::new(delta, lambda),
last: None,
}
}
pub fn observe(&mut self, now: Timestamp) -> Option<ChangePoint> {
let out = if let Some(prev) = self.last {
let gap = now.to_duration().saturating_sub(prev.to_duration());
self.inner.observe(gap.as_secs_f64())
} else {
None
};
self.last = Some(now);
out
}
pub fn inner(&self) -> &PageHinkley {
&self.inner
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cusum_detects_upward_shift() {
let mut c = Cusum::new(10.0, 1.0, 5.0);
for _ in 0..50 {
assert!(c.observe(10.0).is_none());
}
let mut fired = None;
for _ in 0..5 {
if let Some(cp) = c.observe(20.0) {
fired = Some(cp);
break;
}
}
assert_eq!(fired.unwrap().direction, ChangeDirection::Up);
}
#[test]
fn cusum_detects_downward_shift() {
let mut c = Cusum::new(10.0, 1.0, 5.0);
for _ in 0..20 {
c.observe(10.0);
}
let mut fired = None;
for _ in 0..5 {
if let Some(cp) = c.observe(0.0) {
fired = Some(cp);
break;
}
}
assert_eq!(fired.unwrap().direction, ChangeDirection::Down);
}
#[test]
fn cusum_resets_after_alarm() {
let mut c = Cusum::new(0.0, 0.0, 3.0);
let cp = c.observe(10.0).expect("alarm");
assert_eq!(cp.direction, ChangeDirection::Up);
assert!(c.observe(0.0).is_none());
assert_eq!(c.sums().0, 0.0);
}
#[test]
fn cusum_stable_stream_is_quiet() {
let mut c = Cusum::new(100.0, 5.0, 20.0);
for i in 0..1000 {
let x = 100.0 + if i % 2 == 0 { 3.0 } else { -3.0 };
assert!(c.observe(x).is_none());
}
}
#[test]
fn page_hinkley_detects_upward_shift_without_target() {
let mut ph = PageHinkley::new(1.0, 10.0);
for _ in 0..100 {
ph.observe(5.0);
}
let mut fired = None;
for _ in 0..20 {
if let Some(cp) = ph.observe(25.0) {
fired = Some(cp);
break;
}
}
assert_eq!(fired.unwrap().direction, ChangeDirection::Up);
}
#[test]
fn page_hinkley_detects_downward_shift() {
let mut ph = PageHinkley::new(1.0, 10.0);
for _ in 0..100 {
ph.observe(50.0);
}
let mut fired = None;
for _ in 0..30 {
if let Some(cp) = ph.observe(5.0) {
fired = Some(cp);
break;
}
}
assert_eq!(fired.unwrap().direction, ChangeDirection::Down);
}
#[test]
fn page_hinkley_stable_stream_is_quiet() {
let mut ph = PageHinkley::new(2.0, 50.0);
for i in 0..2000 {
let x = 20.0 + if i % 2 == 0 { 1.0 } else { -1.0 };
assert!(ph.observe(x).is_none());
}
}
#[test]
fn inter_arrival_first_sample_seeds() {
let mut d = InterArrivalPageHinkley::new(0.1, 5.0);
assert!(d.observe(Timestamp::new(0, 0)).is_none());
for s in 1..30 {
assert!(d.observe(Timestamp::new(s, 0)).is_none());
}
assert!(d.inner().count() >= 1);
}
#[test]
fn change_direction_slugs() {
assert_eq!(ChangeDirection::Up.as_str(), "up");
assert_eq!(ChangeDirection::Down.as_str(), "down");
}
}