use crate::Direction;
#[derive(Debug, Clone)]
#[allow(clippy::struct_excessive_bools)]
pub struct CusumF64 {
target: f64,
slack_upper: f64,
slack_lower: f64,
threshold_upper: f64,
threshold_lower: f64,
upper: f64,
lower: f64,
count: u64,
min_samples: u64,
slack_upper_explicit: bool,
slack_lower_explicit: bool,
threshold_upper_explicit: bool,
threshold_lower_explicit: bool,
}
#[derive(Debug, Clone)]
pub struct CusumF64Builder {
target: f64,
slack_upper: Option<f64>,
slack_lower: Option<f64>,
threshold_upper: Option<f64>,
threshold_lower: Option<f64>,
min_samples: u64,
seed_upper: Option<f64>,
seed_lower: Option<f64>,
}
impl CusumF64 {
#[inline]
#[must_use]
pub fn builder(target: f64) -> CusumF64Builder {
CusumF64Builder {
target,
slack_upper: None,
slack_lower: None,
threshold_upper: None,
threshold_lower: None,
min_samples: 0,
seed_upper: None,
seed_lower: None,
}
}
#[inline]
pub fn update(&mut self, sample: f64) -> Result<Option<Direction>, crate::DataError> {
check_finite!(sample);
self.count += 1;
let diff = sample - self.target;
let s_high = self.upper + diff - self.slack_upper;
self.upper = s_high.max(0.0);
let s_low = self.lower - diff - self.slack_lower;
self.lower = s_low.max(0.0);
if self.count < self.min_samples {
return Ok(None);
}
Ok(if self.upper > self.threshold_upper {
Some(Direction::Rising)
} else if self.lower > self.threshold_lower {
Some(Direction::Falling)
} else {
Some(Direction::Neutral)
})
}
#[inline]
#[must_use]
pub fn upper(&self) -> f64 {
self.upper
}
#[inline]
#[must_use]
pub fn lower(&self) -> f64 {
self.lower
}
#[inline]
#[must_use]
pub fn count(&self) -> u64 {
self.count
}
#[inline]
#[must_use]
pub fn is_primed(&self) -> bool {
self.count >= self.min_samples
}
#[inline]
pub fn reset(&mut self) {
self.upper = 0.0;
self.lower = 0.0;
self.count = 0;
}
#[inline]
pub fn reset_with_target(&mut self, new_target: f64) {
self.target = new_target;
self.upper = 0.0;
self.lower = 0.0;
self.count = 0;
if !self.slack_upper_explicit {
self.slack_upper = CusumF64Builder::default_slack(new_target);
}
if !self.slack_lower_explicit {
self.slack_lower = CusumF64Builder::default_slack(new_target);
}
if !self.threshold_upper_explicit {
self.threshold_upper = CusumF64Builder::default_threshold(new_target);
}
if !self.threshold_lower_explicit {
self.threshold_lower = CusumF64Builder::default_threshold(new_target);
}
}
#[inline]
#[must_use]
pub fn target(&self) -> f64 {
self.target
}
#[inline]
#[must_use]
pub fn slack_upper(&self) -> f64 {
self.slack_upper
}
#[inline]
#[must_use]
pub fn slack_lower(&self) -> f64 {
self.slack_lower
}
#[inline]
#[must_use]
pub fn threshold_upper(&self) -> f64 {
self.threshold_upper
}
#[inline]
#[must_use]
pub fn threshold_lower(&self) -> f64 {
self.threshold_lower
}
#[inline]
#[must_use]
pub fn min_samples(&self) -> u64 {
self.min_samples
}
#[inline]
pub fn reconfigure(
&mut self,
target: f64,
slack_upper: f64,
slack_lower: f64,
threshold_upper: f64,
threshold_lower: f64,
) -> Result<(), crate::ConfigError> {
if slack_upper < 0.0 {
return Err(crate::ConfigError::Invalid(
"slack_upper must be non-negative",
));
}
if slack_lower < 0.0 {
return Err(crate::ConfigError::Invalid(
"slack_lower must be non-negative",
));
}
if threshold_upper <= 0.0 {
return Err(crate::ConfigError::Invalid(
"threshold_upper must be positive",
));
}
if threshold_lower <= 0.0 {
return Err(crate::ConfigError::Invalid(
"threshold_lower must be positive",
));
}
self.target = target;
self.slack_upper = slack_upper;
self.slack_lower = slack_lower;
self.threshold_upper = threshold_upper;
self.threshold_lower = threshold_lower;
self.slack_upper_explicit = true;
self.slack_lower_explicit = true;
self.threshold_upper_explicit = true;
self.threshold_lower_explicit = true;
Ok(())
}
}
impl CusumF64Builder {
#[inline]
fn default_slack(target: f64) -> f64 {
let abs_target = target.abs();
let slack = abs_target / 20.0;
if slack < 0.0 { 0.0 } else { slack }
}
#[inline]
fn default_threshold(target: f64) -> f64 {
let abs_target = target.abs();
abs_target / 2.0
}
#[inline]
#[must_use]
pub fn slack(mut self, slack: f64) -> Self {
self.slack_upper = Some(slack);
self.slack_lower = Some(slack);
self
}
#[inline]
#[must_use]
pub fn slack_upper(mut self, slack: f64) -> Self {
self.slack_upper = Some(slack);
self
}
#[inline]
#[must_use]
pub fn slack_lower(mut self, slack: f64) -> Self {
self.slack_lower = Some(slack);
self
}
#[inline]
#[must_use]
pub fn threshold(mut self, threshold: f64) -> Self {
self.threshold_upper = Some(threshold);
self.threshold_lower = Some(threshold);
self
}
#[inline]
#[must_use]
pub fn threshold_upper(mut self, threshold: f64) -> Self {
self.threshold_upper = Some(threshold);
self
}
#[inline]
#[must_use]
pub fn threshold_lower(mut self, threshold: f64) -> Self {
self.threshold_lower = Some(threshold);
self
}
#[inline]
#[must_use]
pub fn min_samples(mut self, min: u64) -> Self {
self.min_samples = min;
self
}
#[inline]
#[must_use]
pub fn seed_upper(mut self, val: f64) -> Self {
self.seed_upper = Some(val);
self
}
#[inline]
#[must_use]
pub fn seed_lower(mut self, val: f64) -> Self {
self.seed_lower = Some(val);
self
}
#[inline]
pub fn build(self) -> Result<CusumF64, crate::ConfigError> {
let slack_upper_explicit = self.slack_upper.is_some();
let slack_lower_explicit = self.slack_lower.is_some();
let threshold_upper_explicit = self.threshold_upper.is_some();
let threshold_lower_explicit = self.threshold_lower.is_some();
let slack_upper = self
.slack_upper
.unwrap_or_else(|| Self::default_slack(self.target));
let slack_lower = self
.slack_lower
.unwrap_or_else(|| Self::default_slack(self.target));
let threshold_upper = self
.threshold_upper
.unwrap_or_else(|| Self::default_threshold(self.target));
let threshold_lower = self
.threshold_lower
.unwrap_or_else(|| Self::default_threshold(self.target));
if slack_upper < 0.0 {
return Err(crate::ConfigError::Invalid(
"slack_upper must be non-negative",
));
}
if slack_lower < 0.0 {
return Err(crate::ConfigError::Invalid(
"slack_lower must be non-negative",
));
}
if threshold_upper <= 0.0 {
return Err(crate::ConfigError::Invalid(
"threshold_upper must be positive",
));
}
if threshold_lower <= 0.0 {
return Err(crate::ConfigError::Invalid(
"threshold_lower must be positive",
));
}
let seeded = self.seed_upper.is_some() || self.seed_lower.is_some();
let initial_count = if seeded { self.min_samples } else { 0 };
Ok(CusumF64 {
target: self.target,
slack_upper,
slack_lower,
threshold_upper,
threshold_lower,
upper: self.seed_upper.unwrap_or(0.0),
lower: self.seed_lower.unwrap_or(0.0),
count: initial_count,
min_samples: self.min_samples,
slack_upper_explicit,
slack_lower_explicit,
threshold_upper_explicit,
threshold_lower_explicit,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_upward_shift() {
let mut cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold(50.0)
.build()
.unwrap();
for _ in 0..10 {
let result = cusum.update(100.0).unwrap();
assert_eq!(result, Some(Direction::Neutral));
}
let mut triggered = false;
for _ in 0..100 {
if cusum.update(120.0).unwrap() == Some(Direction::Rising) {
triggered = true;
break;
}
}
assert!(triggered, "should have detected upward shift");
}
#[test]
fn detects_downward_shift() {
let mut cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold(50.0)
.build()
.unwrap();
let mut triggered = false;
for _ in 0..100 {
if cusum.update(80.0).unwrap() == Some(Direction::Falling) {
triggered = true;
break;
}
}
assert!(triggered, "should have detected downward shift");
}
#[test]
fn no_false_positive_at_target() {
let mut cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold(50.0)
.build()
.unwrap();
for _ in 0..1000 {
assert_eq!(cusum.update(100.0).unwrap(), Some(Direction::Neutral));
}
}
#[test]
fn returns_none_before_primed() {
let mut cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold(50.0)
.min_samples(10)
.build()
.unwrap();
for _ in 0..9 {
assert_eq!(cusum.update(200.0).unwrap(), None);
}
assert!(!cusum.is_primed());
let result = cusum.update(200.0).unwrap();
assert!(result.is_some());
assert!(cusum.is_primed());
}
#[test]
fn primed_immediately_with_zero_min_samples() {
let mut cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold(50.0)
.build()
.unwrap();
assert_eq!(cusum.min_samples(), 0);
assert!(cusum.update(100.0).unwrap().is_some());
}
#[test]
#[allow(clippy::float_cmp)]
fn reset_clears_state() {
let mut cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold(50.0)
.build()
.unwrap();
for _ in 0..10 {
let _ = cusum.update(120.0);
}
assert!(cusum.upper() > 0.0);
assert!(cusum.count() > 0);
cusum.reset();
assert_eq!(cusum.upper(), 0.0);
assert_eq!(cusum.lower(), 0.0);
assert_eq!(cusum.count(), 0);
}
#[test]
fn reset_with_target_updates_defaults() {
let mut cusum = CusumF64::builder(100.0).build().unwrap();
let original_slack = cusum.slack_upper();
let original_threshold = cusum.threshold_upper();
cusum.reset_with_target(200.0);
assert!(cusum.slack_upper() > original_slack);
assert!(cusum.threshold_upper() > original_threshold);
}
#[test]
#[allow(clippy::float_cmp)]
fn reset_with_target_preserves_explicit_params() {
let mut cusum = CusumF64::builder(100.0)
.slack(10.0)
.threshold(75.0)
.build()
.unwrap();
cusum.reset_with_target(200.0);
assert_eq!(cusum.slack_upper(), 10.0);
assert_eq!(cusum.slack_lower(), 10.0);
assert_eq!(cusum.threshold_upper(), 75.0);
assert_eq!(cusum.threshold_lower(), 75.0);
}
#[test]
fn asymmetric_slack() {
let mut cusum = CusumF64::builder(100.0)
.slack_upper(2.0)
.slack_lower(10.0)
.threshold(50.0)
.build()
.unwrap();
for _ in 0..10 {
let _ = cusum.update(110.0);
}
let upper_after = cusum.upper();
cusum.reset();
for _ in 0..10 {
let _ = cusum.update(90.0);
}
let lower_after = cusum.lower();
assert!(
upper_after > lower_after,
"upper ({upper_after}) should accumulate faster than lower ({lower_after}) with tighter slack"
);
}
#[test]
fn asymmetric_threshold() {
let mut cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold_upper(20.0) .threshold_lower(500.0) .build()
.unwrap();
let mut upper_triggered = false;
for _ in 0..20 {
if cusum.update(120.0).unwrap() == Some(Direction::Rising) {
upper_triggered = true;
break;
}
}
assert!(upper_triggered);
cusum.reset();
let mut lower_triggered = false;
for _ in 0..20 {
if cusum.update(80.0).unwrap() == Some(Direction::Falling) {
lower_triggered = true;
break;
}
}
assert!(
!lower_triggered,
"lower should not trigger with high threshold"
);
}
#[test]
#[allow(clippy::float_cmp)]
fn symmetric_slack_sets_both() {
let cusum = CusumF64::builder(100.0).slack(7.5).build().unwrap();
assert_eq!(cusum.slack_upper(), 7.5);
assert_eq!(cusum.slack_lower(), 7.5);
}
#[test]
#[allow(clippy::float_cmp)]
fn symmetric_threshold_sets_both() {
let cusum = CusumF64::builder(100.0).threshold(42.0).build().unwrap();
assert_eq!(cusum.threshold_upper(), 42.0);
assert_eq!(cusum.threshold_lower(), 42.0);
}
#[test]
fn rejects_negative_slack_upper() {
let result = CusumF64::builder(100.0).slack_upper(-1.0).build();
assert!(matches!(
result,
Err(crate::ConfigError::Invalid(
"slack_upper must be non-negative"
))
));
}
#[test]
fn rejects_negative_slack_lower() {
let result = CusumF64::builder(100.0).slack_lower(-1.0).build();
assert!(matches!(
result,
Err(crate::ConfigError::Invalid(
"slack_lower must be non-negative"
))
));
}
#[test]
fn rejects_zero_threshold() {
let result = CusumF64::builder(100.0).threshold(0.0).build();
assert!(matches!(
result,
Err(crate::ConfigError::Invalid(
"threshold_upper must be positive"
))
));
}
#[test]
fn rejects_negative_threshold_lower() {
let result = CusumF64::builder(100.0).threshold_lower(-1.0).build();
assert!(matches!(
result,
Err(crate::ConfigError::Invalid(
"threshold_lower must be positive"
))
));
}
#[test]
fn count_increments() {
let mut cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold(50.0)
.build()
.unwrap();
assert_eq!(cusum.count(), 0);
let _ = cusum.update(100.0);
assert_eq!(cusum.count(), 1);
let _ = cusum.update(100.0);
assert_eq!(cusum.count(), 2);
}
#[test]
#[allow(clippy::float_cmp)]
fn upper_and_lower_start_at_zero() {
let cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold(50.0)
.build()
.unwrap();
assert_eq!(cusum.upper(), 0.0);
assert_eq!(cusum.lower(), 0.0);
}
#[test]
#[allow(clippy::float_cmp)]
fn cusum_at_exactly_slack_no_accumulation() {
let mut cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold(50.0)
.build()
.unwrap();
let _ = cusum.update(105.0);
assert_eq!(cusum.upper(), 0.0);
}
#[test]
#[allow(clippy::float_cmp)]
fn reconfigure_changes_params_preserves_state() {
let mut cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold(50.0)
.build()
.unwrap();
for _ in 0..5 {
let _ = cusum.update(120.0);
}
let upper_before = cusum.upper();
let count_before = cusum.count();
assert!(upper_before > 0.0);
cusum.reconfigure(200.0, 10.0, 10.0, 100.0, 100.0).unwrap();
assert_eq!(cusum.target(), 200.0);
assert_eq!(cusum.slack_upper(), 10.0);
assert_eq!(cusum.slack_lower(), 10.0);
assert_eq!(cusum.threshold_upper(), 100.0);
assert_eq!(cusum.threshold_lower(), 100.0);
assert_eq!(cusum.upper(), upper_before);
assert_eq!(cusum.count(), count_before);
}
#[test]
fn reconfigure_validates() {
let mut cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold(50.0)
.build()
.unwrap();
assert!(cusum.reconfigure(100.0, -1.0, 0.0, 1.0, 1.0).is_err());
assert!(cusum.reconfigure(100.0, 0.0, -1.0, 1.0, 1.0).is_err());
assert!(cusum.reconfigure(100.0, 0.0, 0.0, 0.0, 1.0).is_err());
assert!(cusum.reconfigure(100.0, 0.0, 0.0, 1.0, 0.0).is_err());
}
#[test]
fn rejects_nan_and_inf() {
let mut cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold(50.0)
.build()
.unwrap();
assert_eq!(
cusum.update(f64::NAN).unwrap_err(),
crate::DataError::NotANumber
);
assert_eq!(
cusum.update(f64::INFINITY).unwrap_err(),
crate::DataError::Infinite
);
assert_eq!(
cusum.update(f64::NEG_INFINITY).unwrap_err(),
crate::DataError::Infinite
);
assert_eq!(cusum.count(), 0);
}
}