use crate::domain::metrics::{
constants,
values::{MetricValue, StabilityThreshold},
};
use nutype::nutype;
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
pub enum TrendDirection {
Improving,
Declining,
Stable,
}
impl TrendDirection {
pub fn from_values(
current: MetricValue,
previous: MetricValue,
stability_threshold: StabilityThreshold,
) -> Self {
if let Some(percentage_change) = current.percentage_change_from(previous) {
if percentage_change.into_inner().abs() <= stability_threshold.into_inner() {
Self::Stable
} else if percentage_change.is_improvement() {
Self::Improving
} else {
Self::Declining
}
} else {
let change = current.into_inner() - previous.into_inner();
if change.abs() <= stability_threshold.into_inner() {
Self::Stable
} else if change > 0.0 {
Self::Improving
} else {
Self::Declining
}
}
}
}
impl fmt::Display for TrendDirection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Improving => write!(f, "improving"),
Self::Declining => write!(f, "declining"),
Self::Stable => write!(f, "stable"),
}
}
}
#[nutype(
validate(finite, greater_or_equal = 0.0, less_or_equal = 1.0),
derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)
)]
pub struct TrendMagnitude(f64);
impl TrendMagnitude {
pub fn negligible() -> Self {
Self::try_new(0.01).unwrap() }
pub fn small() -> Self {
Self::try_new(0.05).unwrap() }
pub fn moderate() -> Self {
Self::try_new(0.1).unwrap() }
pub fn large() -> Self {
Self::try_new(0.2).unwrap() }
pub fn from_values(current: MetricValue, previous: MetricValue) -> Result<Self, TrendError> {
if previous.into_inner() == 0.0 {
return Err(TrendError::ZeroDivision);
}
let magnitude =
((current.into_inner() - previous.into_inner()) / previous.into_inner()).abs();
Self::try_new(magnitude).map_err(|_| TrendError::InvalidMagnitude(magnitude))
}
pub fn category(&self) -> TrendCategory {
match self.into_inner() {
x if x <= constants::statistical::DEFAULT_STABILITY_THRESHOLD => {
TrendCategory::Negligible
}
x if x <= 0.05 => TrendCategory::Small,
x if x <= 0.15 => TrendCategory::Moderate,
_ => TrendCategory::Large,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TrendCategory {
Negligible,
Small,
Moderate,
Large,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TrendAnalysis {
pub direction: TrendDirection,
pub magnitude: TrendMagnitude,
pub category: TrendCategory,
}
impl TrendAnalysis {
pub fn from_values(
current: MetricValue,
previous: MetricValue,
stability_threshold: StabilityThreshold,
) -> Result<Self, TrendError> {
let direction = TrendDirection::from_values(current, previous, stability_threshold);
let magnitude = TrendMagnitude::from_values(current, previous)?;
let category = magnitude.category();
Ok(Self {
direction,
magnitude,
category,
})
}
pub fn new(direction: TrendDirection, magnitude: TrendMagnitude) -> Self {
let category = magnitude.category();
Self {
direction,
magnitude,
category,
}
}
pub fn is_significant(&self) -> bool {
!matches!(self.category, TrendCategory::Negligible)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum TrendError {
ZeroDivision,
InvalidMagnitude(f64),
}
impl fmt::Display for TrendError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ZeroDivision => write!(f, "Cannot calculate trend: previous value is zero"),
Self::InvalidMagnitude(value) => write!(f, "Invalid trend magnitude: {value}"),
}
}
}
impl std::error::Error for TrendError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_trend_direction_from_values() {
let threshold = StabilityThreshold::try_new(0.01).unwrap();
assert_eq!(
TrendDirection::from_values(
MetricValue::try_new(0.85).unwrap(),
MetricValue::try_new(0.80).unwrap(),
threshold
),
TrendDirection::Improving
);
assert_eq!(
TrendDirection::from_values(
MetricValue::try_new(0.75).unwrap(),
MetricValue::try_new(0.80).unwrap(),
threshold
),
TrendDirection::Declining
);
assert_eq!(
TrendDirection::from_values(
MetricValue::try_new(0.805).unwrap(),
MetricValue::try_new(0.800).unwrap(),
threshold
),
TrendDirection::Stable
);
}
#[test]
fn test_trend_magnitude_validation() {
assert!(TrendMagnitude::try_new(0.0).is_ok());
assert!(TrendMagnitude::try_new(0.5).is_ok());
assert!(TrendMagnitude::try_new(1.0).is_ok());
assert!(TrendMagnitude::try_new(-0.1).is_err());
assert!(TrendMagnitude::try_new(1.1).is_err());
}
#[test]
fn test_trend_magnitude_from_values() {
let magnitude = TrendMagnitude::from_values(
MetricValue::try_new(1.0).unwrap(),
MetricValue::try_new(0.9).unwrap(),
)
.unwrap();
assert!((magnitude.into_inner() - (0.1 / 0.9)).abs() < 1e-10);
let magnitude = TrendMagnitude::from_values(
MetricValue::try_new(0.9).unwrap(),
MetricValue::try_new(1.0).unwrap(),
)
.unwrap();
assert!((magnitude.into_inner() - 0.1).abs() < 1e-10);
assert!(TrendMagnitude::from_values(
MetricValue::try_new(1.0).unwrap(),
MetricValue::try_new(0.0).unwrap()
)
.is_err());
}
#[test]
fn test_trend_analysis() {
let analysis = TrendAnalysis::from_values(
MetricValue::try_new(1.0).unwrap(),
MetricValue::try_new(0.9).unwrap(),
StabilityThreshold::try_new(0.01).unwrap(),
)
.unwrap();
assert_eq!(analysis.direction, TrendDirection::Improving);
assert_eq!(analysis.category, TrendCategory::Moderate);
assert!(analysis.is_significant());
}
}