use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnergyHistoryConfig {
pub max_entries: usize,
pub trend_window: usize,
pub persistence_window_secs: u64,
pub anomaly_sigma: f32,
pub min_entries: usize,
}
impl Default for EnergyHistoryConfig {
fn default() -> Self {
Self {
max_entries: 1000,
trend_window: 10,
persistence_window_secs: 300, anomaly_sigma: 3.0,
min_entries: 5,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TrendDirection {
Increasing,
Decreasing,
Stable,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnergyTrend {
pub direction: TrendDirection,
pub slope: f32,
pub r_squared: f32,
pub mean: f32,
pub std_dev: f32,
pub window_size: usize,
}
impl EnergyTrend {
pub fn is_concerning(&self, threshold: f32) -> bool {
self.direction == TrendDirection::Increasing && self.slope > threshold
}
pub fn is_improving(&self) -> bool {
self.direction == TrendDirection::Decreasing && self.r_squared > 0.5
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct HistoryEntry {
energy: f32,
timestamp: DateTime<Utc>,
is_anomaly: bool,
}
#[derive(Debug)]
pub struct EnergyHistory {
config: EnergyHistoryConfig,
entries: VecDeque<HistoryEntry>,
running_sum: f64,
running_sum_sq: f64,
last_trend: Option<EnergyTrend>,
total_entries: u64,
anomaly_count: u64,
}
impl EnergyHistory {
pub fn new(config: EnergyHistoryConfig) -> Self {
Self {
config,
entries: VecDeque::new(),
running_sum: 0.0,
running_sum_sq: 0.0,
last_trend: None,
total_entries: 0,
anomaly_count: 0,
}
}
pub fn record(&mut self, energy: f32) {
self.record_at(energy, Utc::now());
}
pub fn record_at(&mut self, energy: f32, timestamp: DateTime<Utc>) {
let is_anomaly = self.is_anomaly(energy);
if is_anomaly {
self.anomaly_count += 1;
}
let entry = HistoryEntry {
energy,
timestamp,
is_anomaly,
};
self.running_sum += energy as f64;
self.running_sum_sq += (energy as f64) * (energy as f64);
self.entries.push_back(entry);
self.total_entries += 1;
while self.entries.len() > self.config.max_entries {
if let Some(old) = self.entries.pop_front() {
self.running_sum -= old.energy as f64;
self.running_sum_sq -= (old.energy as f64) * (old.energy as f64);
}
}
self.last_trend = None;
}
pub fn current(&self) -> Option<f32> {
self.entries.back().map(|e| e.energy)
}
pub fn previous(&self) -> Option<f32> {
if self.entries.len() >= 2 {
self.entries.get(self.entries.len() - 2).map(|e| e.energy)
} else {
None
}
}
pub fn delta(&self) -> Option<f32> {
match (self.current(), self.previous()) {
(Some(curr), Some(prev)) => Some(curr - prev),
_ => None,
}
}
pub fn mean(&self) -> f32 {
if self.entries.is_empty() {
0.0
} else {
(self.running_sum / self.entries.len() as f64) as f32
}
}
pub fn std_dev(&self) -> f32 {
let n = self.entries.len();
if n < 2 {
return 0.0;
}
let mean = self.running_sum / n as f64;
let variance = (self.running_sum_sq / n as f64) - (mean * mean);
if variance > 0.0 {
(variance.sqrt()) as f32
} else {
0.0
}
}
pub fn min(&self) -> Option<f32> {
self.entries
.iter()
.map(|e| e.energy)
.min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
}
pub fn max(&self) -> Option<f32> {
self.entries
.iter()
.map(|e| e.energy)
.max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
}
pub fn trend(&mut self) -> EnergyTrend {
if let Some(ref trend) = self.last_trend {
return trend.clone();
}
let trend = self.compute_trend();
self.last_trend = Some(trend.clone());
trend
}
pub fn is_above_threshold_persistent(&self, threshold: f32) -> bool {
let window = Duration::seconds(self.config.persistence_window_secs as i64);
let cutoff = Utc::now() - window;
let recent: Vec<_> = self
.entries
.iter()
.rev()
.take_while(|e| e.timestamp >= cutoff)
.collect();
if recent.is_empty() {
return false;
}
recent.iter().all(|e| e.energy > threshold)
}
pub fn is_below_threshold_persistent(&self, threshold: f32) -> bool {
let window = Duration::seconds(self.config.persistence_window_secs as i64);
let cutoff = Utc::now() - window;
let recent: Vec<_> = self
.entries
.iter()
.rev()
.take_while(|e| e.timestamp >= cutoff)
.collect();
if recent.is_empty() {
return false;
}
recent.iter().all(|e| e.energy < threshold)
}
pub fn recent_entries(&self, seconds: u64) -> Vec<(f32, DateTime<Utc>)> {
let window = Duration::seconds(seconds as i64);
let cutoff = Utc::now() - window;
self.entries
.iter()
.rev()
.take_while(|e| e.timestamp >= cutoff)
.map(|e| (e.energy, e.timestamp))
.collect()
}
#[inline]
pub fn len(&self) -> usize {
self.entries.len()
}
#[inline]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[inline]
pub fn total_entries(&self) -> u64 {
self.total_entries
}
#[inline]
pub fn anomaly_count(&self) -> u64 {
self.anomaly_count
}
pub fn anomaly_rate(&self) -> f32 {
if self.total_entries > 0 {
self.anomaly_count as f32 / self.total_entries as f32
} else {
0.0
}
}
pub fn clear(&mut self) {
self.entries.clear();
self.running_sum = 0.0;
self.running_sum_sq = 0.0;
self.last_trend = None;
}
fn is_anomaly(&self, energy: f32) -> bool {
if self.entries.len() < self.config.min_entries {
return false;
}
let mean = self.mean();
let std_dev = self.std_dev();
if std_dev < 1e-10 {
return false;
}
let z_score = ((energy - mean) / std_dev).abs();
z_score > self.config.anomaly_sigma
}
fn compute_trend(&self) -> EnergyTrend {
let window_size = self.config.trend_window.min(self.entries.len());
if window_size < self.config.min_entries {
return EnergyTrend {
direction: TrendDirection::Unknown,
slope: 0.0,
r_squared: 0.0,
mean: self.mean(),
std_dev: self.std_dev(),
window_size,
};
}
let recent: Vec<_> = self.entries.iter().rev().take(window_size).collect();
let n = recent.len() as f64;
let mut sum_x = 0.0;
let mut sum_y = 0.0;
let mut sum_xy = 0.0;
let mut sum_xx = 0.0;
for (i, entry) in recent.iter().rev().enumerate() {
let x = i as f64;
let y = entry.energy as f64;
sum_x += x;
sum_y += y;
sum_xy += x * y;
sum_xx += x * x;
}
let slope = if (n * sum_xx - sum_x * sum_x).abs() > 1e-10 {
((n * sum_xy - sum_x * sum_y) / (n * sum_xx - sum_x * sum_x)) as f32
} else {
0.0
};
let mean_y = sum_y / n;
let mut ss_tot = 0.0;
let mut ss_res = 0.0;
let b = (sum_y - slope as f64 * sum_x) / n;
for (i, entry) in recent.iter().rev().enumerate() {
let x = i as f64;
let y = entry.energy as f64;
let y_pred = slope as f64 * x + b;
ss_tot += (y - mean_y).powi(2);
ss_res += (y - y_pred).powi(2);
}
let r_squared = if ss_tot > 1e-10 {
(1.0 - ss_res / ss_tot) as f32
} else {
0.0
};
let direction = if slope.abs() < 0.001 {
TrendDirection::Stable
} else if slope > 0.0 {
TrendDirection::Increasing
} else {
TrendDirection::Decreasing
};
let window_sum: f64 = recent.iter().map(|e| e.energy as f64).sum();
let window_mean = (window_sum / n) as f32;
let window_var: f64 = recent
.iter()
.map(|e| {
let diff = e.energy as f64 - window_sum / n;
diff * diff
})
.sum::<f64>()
/ n;
let window_std_dev = (window_var.sqrt()) as f32;
EnergyTrend {
direction,
slope,
r_squared,
mean: window_mean,
std_dev: window_std_dev,
window_size,
}
}
}
impl Default for EnergyHistory {
fn default() -> Self {
Self::new(EnergyHistoryConfig::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_history_creation() {
let history = EnergyHistory::default();
assert!(history.is_empty());
assert_eq!(history.len(), 0);
}
#[test]
fn test_record_energy() {
let mut history = EnergyHistory::default();
history.record(1.0);
history.record(2.0);
history.record(3.0);
assert_eq!(history.len(), 3);
assert_eq!(history.current(), Some(3.0));
assert_eq!(history.previous(), Some(2.0));
assert_eq!(history.delta(), Some(1.0));
}
#[test]
fn test_statistics() {
let mut history = EnergyHistory::default();
history.record(1.0);
history.record(2.0);
history.record(3.0);
history.record(4.0);
history.record(5.0);
assert_eq!(history.mean(), 3.0);
assert_eq!(history.min(), Some(1.0));
assert_eq!(history.max(), Some(5.0));
}
#[test]
fn test_trend_increasing() {
let mut history = EnergyHistory::new(EnergyHistoryConfig {
min_entries: 3,
trend_window: 5,
..Default::default()
});
for i in 0..10 {
history.record(i as f32);
}
let trend = history.trend();
assert_eq!(trend.direction, TrendDirection::Increasing);
assert!(trend.slope > 0.0);
}
#[test]
fn test_trend_decreasing() {
let mut history = EnergyHistory::new(EnergyHistoryConfig {
min_entries: 3,
trend_window: 5,
..Default::default()
});
for i in (0..10).rev() {
history.record(i as f32);
}
let trend = history.trend();
assert_eq!(trend.direction, TrendDirection::Decreasing);
assert!(trend.slope < 0.0);
}
#[test]
fn test_trend_stable() {
let mut history = EnergyHistory::new(EnergyHistoryConfig {
min_entries: 3,
trend_window: 5,
..Default::default()
});
for _ in 0..10 {
history.record(5.0);
}
let trend = history.trend();
assert_eq!(trend.direction, TrendDirection::Stable);
assert!(trend.slope.abs() < 0.01);
}
#[test]
fn test_anomaly_detection() {
let config = EnergyHistoryConfig {
anomaly_sigma: 2.0,
min_entries: 5,
..Default::default()
};
let mut history = EnergyHistory::new(config);
for _ in 0..10 {
history.record(5.0);
}
history.record(100.0);
assert!(history.anomaly_count() > 0);
}
#[test]
fn test_history_trimming() {
let config = EnergyHistoryConfig {
max_entries: 5,
..Default::default()
};
let mut history = EnergyHistory::new(config);
for i in 0..10 {
history.record(i as f32);
}
assert_eq!(history.len(), 5);
assert_eq!(history.total_entries(), 10);
assert_eq!(history.min(), Some(5.0));
}
#[test]
fn test_clear() {
let mut history = EnergyHistory::default();
history.record(1.0);
history.record(2.0);
history.clear();
assert!(history.is_empty());
assert_eq!(history.current(), None);
}
}