use crate::powerquality::sag_swell::{detect_voltage_events, half_cycle_rms, VoltageEventType};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum PqEventClass {
VoltageSag(VoltageEventType),
VoltageSwell(VoltageEventType),
Interruption,
Transient,
HarmonicIncrease,
FrequencyDeviation,
VoltageFlicker,
Notch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum PqSeverity {
Minor,
Moderate,
Severe,
Critical,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PqEvent {
pub start_sample: usize,
pub end_sample: usize,
pub event_class: PqEventClass,
pub severity: PqSeverity,
pub rms_impact: f64,
pub frequency_impact: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PqEventSummary {
pub total_events: usize,
pub events_per_class: Vec<(String, usize)>,
pub events_per_hour: f64,
pub most_severe: Option<PqEvent>,
pub cumulative_duration_s: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PqEventClassifier {
pub sample_rate_hz: f64,
pub nominal_freq_hz: f64,
pub nominal_voltage_pu: f64,
}
impl PqEventClassifier {
pub fn new(sample_rate_hz: f64, nominal_freq_hz: f64) -> Self {
Self {
sample_rate_hz,
nominal_freq_hz,
nominal_voltage_pu: 1.0,
}
}
pub fn classify_events(&self, waveform: &[f64]) -> Vec<PqEvent> {
if waveform.is_empty() {
return vec![];
}
let mut events: Vec<PqEvent> = Vec::new();
let rms_env = half_cycle_rms(waveform, self.sample_rate_hz, self.nominal_freq_hz);
let raw_events = detect_voltage_events(
&rms_env,
self.sample_rate_hz,
self.nominal_freq_hz,
0.9,
1.1,
0.1,
);
for ev in raw_events {
let rms_impact = (ev.retained_voltage - 1.0).abs();
let (event_class, severity) = match ev.event_type {
VoltageEventType::Interruption => {
(PqEventClass::Interruption, PqSeverity::Critical)
}
VoltageEventType::InstantaneousSag
| VoltageEventType::MomentarySag
| VoltageEventType::TemporarySag
| VoltageEventType::Undervoltage => {
let sev = severity_for_sag(ev.retained_voltage);
(PqEventClass::VoltageSag(ev.event_type), sev)
}
VoltageEventType::InstantaneousSwell
| VoltageEventType::MomentarySwell
| VoltageEventType::TemporarySwell
| VoltageEventType::Overvoltage => {
let sev = severity_for_swell(ev.retained_voltage);
(PqEventClass::VoltageSwell(ev.event_type), sev)
}
VoltageEventType::Normal => continue,
};
events.push(PqEvent {
start_sample: ev.start_sample,
end_sample: ev.end_sample,
event_class,
severity,
rms_impact,
frequency_impact: 0.0,
});
}
let transients = self.detect_transients(waveform, 32);
for (sample, peak_mag) in transients {
let rms_impact = (peak_mag.abs() - self.nominal_voltage_pu).max(0.0);
let severity = if peak_mag.abs() > 2.0 {
PqSeverity::Critical
} else if peak_mag.abs() > 1.8 {
PqSeverity::Severe
} else if peak_mag.abs() > 1.5 {
PqSeverity::Moderate
} else {
PqSeverity::Minor
};
events.push(PqEvent {
start_sample: sample,
end_sample: sample + 1,
event_class: PqEventClass::Transient,
severity,
rms_impact,
frequency_impact: 0.0,
});
}
let notches = self.detect_notches(waveform);
for (start, end) in notches {
let region = &waveform[start..=end.min(waveform.len() - 1)];
let notch_depth = region.iter().copied().fold(f64::INFINITY, f64::min).abs();
let rms_impact = notch_depth;
events.push(PqEvent {
start_sample: start,
end_sample: end,
event_class: PqEventClass::Notch,
severity: PqSeverity::Minor,
rms_impact,
frequency_impact: 0.0,
});
}
events.sort_by_key(|e| e.start_sample);
events
}
pub fn detect_transients(&self, waveform: &[f64], window_samples: usize) -> Vec<(usize, f64)> {
if waveform.is_empty() || window_samples == 0 {
return vec![];
}
let transient_threshold = 1.5_f64;
let rms_jump_threshold = 0.5_f64;
let mut transients = Vec::new();
let mut last_reported = usize::MAX;
for (idx, &v) in waveform.iter().enumerate() {
if v.abs() > transient_threshold {
let debounce = window_samples.max(1);
if last_reported == usize::MAX || idx >= last_reported + debounce {
transients.push((idx, v));
last_reported = idx;
}
}
}
let n_windows = waveform.len() / window_samples;
let mut prev_rms = 0.0_f64;
for w in 0..n_windows {
let start = w * window_samples;
let end = start + window_samples;
let window = &waveform[start..end];
let rms = (window.iter().map(|&v| v * v).sum::<f64>() / window_samples as f64).sqrt();
if w > 0 && (rms - prev_rms).abs() > rms_jump_threshold && rms > 1.0 {
let debounce = window_samples.max(1);
if last_reported == usize::MAX || start >= last_reported + debounce {
let (peak_idx, &peak_v) = window
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| {
a.abs()
.partial_cmp(&b.abs())
.unwrap_or(std::cmp::Ordering::Equal)
})
.unwrap_or((0, &0.0));
transients.push((start + peak_idx, peak_v));
last_reported = start;
}
}
prev_rms = rms;
}
transients.sort_by_key(|(s, _)| *s);
transients.dedup_by_key(|(s, _)| *s);
transients
}
pub fn classify_severity(&self, event: &PqEvent) -> PqSeverity {
match &event.event_class {
PqEventClass::Interruption => PqSeverity::Critical,
PqEventClass::VoltageSag(t) => match t {
VoltageEventType::InstantaneousSag => {
if event.rms_impact > 0.7 {
PqSeverity::Severe
} else if event.rms_impact > 0.5 {
PqSeverity::Moderate
} else {
PqSeverity::Minor
}
}
VoltageEventType::MomentarySag | VoltageEventType::TemporarySag => {
if event.rms_impact > 0.5 {
PqSeverity::Critical
} else if event.rms_impact > 0.3 {
PqSeverity::Severe
} else {
PqSeverity::Moderate
}
}
VoltageEventType::Undervoltage => PqSeverity::Minor,
_ => PqSeverity::Minor,
},
PqEventClass::VoltageSwell(_) => {
if event.rms_impact > 0.5 {
PqSeverity::Severe
} else if event.rms_impact > 0.2 {
PqSeverity::Moderate
} else {
PqSeverity::Minor
}
}
PqEventClass::Transient => {
if event.rms_impact > 1.0 {
PqSeverity::Critical
} else if event.rms_impact > 0.5 {
PqSeverity::Severe
} else {
PqSeverity::Moderate
}
}
PqEventClass::HarmonicIncrease | PqEventClass::FrequencyDeviation => PqSeverity::Minor,
PqEventClass::VoltageFlicker => PqSeverity::Minor,
PqEventClass::Notch => PqSeverity::Minor,
}
}
pub fn event_summary(
events: &[PqEvent],
duration_hours: f64,
sample_rate_hz: f64,
) -> PqEventSummary {
let total_events = events.len();
let events_per_hour = if duration_hours > 0.0 {
total_events as f64 / duration_hours
} else {
0.0
};
let class_names = [
"Interruption",
"VoltageSag",
"VoltageSwell",
"Transient",
"HarmonicIncrease",
"FrequencyDeviation",
"VoltageFlicker",
"Notch",
];
let mut counts = vec![0usize; class_names.len()];
for ev in events {
let idx = match &ev.event_class {
PqEventClass::Interruption => 0,
PqEventClass::VoltageSag(_) => 1,
PqEventClass::VoltageSwell(_) => 2,
PqEventClass::Transient => 3,
PqEventClass::HarmonicIncrease => 4,
PqEventClass::FrequencyDeviation => 5,
PqEventClass::VoltageFlicker => 6,
PqEventClass::Notch => 7,
};
counts[idx] += 1;
}
let events_per_class: Vec<(String, usize)> = class_names
.iter()
.zip(counts.iter())
.filter(|(_, &c)| c > 0)
.map(|(&name, &count)| (name.to_string(), count))
.collect();
let most_severe = events
.iter()
.max_by(|a, b| {
a.severity.cmp(&b.severity).then(
a.rms_impact
.partial_cmp(&b.rms_impact)
.unwrap_or(std::cmp::Ordering::Equal),
)
})
.cloned();
let seconds_per_sample = if sample_rate_hz > 0.0 {
1.0 / sample_rate_hz
} else {
0.0
};
let cumulative_duration_s: f64 = events
.iter()
.map(|ev| {
(ev.end_sample.saturating_sub(ev.start_sample) + 1) as f64 * seconds_per_sample
})
.sum();
PqEventSummary {
total_events,
events_per_class,
events_per_hour,
most_severe,
cumulative_duration_s,
}
}
fn detect_notches(&self, waveform: &[f64]) -> Vec<(usize, usize)> {
if waveform.len() < 4 {
return vec![];
}
let max_half_cycle_samples =
(self.sample_rate_hz / (2.0 * self.nominal_freq_hz)).round() as usize;
let deriv_threshold = 2.0 * self.nominal_freq_hz / self.sample_rate_hz * 10.0;
let mut notches = Vec::new();
let mut i = 1;
while i < waveform.len() - 1 {
let deriv = waveform[i] - waveform[i - 1];
if deriv < -deriv_threshold {
let start = i;
let mut j = i + 1;
while j < waveform.len().min(i + max_half_cycle_samples) {
let rec = waveform[j] - waveform[j - 1];
if rec > deriv_threshold {
notches.push((start, j));
i = j + 1;
break;
}
j += 1;
}
if j >= waveform.len().min(i + max_half_cycle_samples) {
i += 1;
}
} else {
i += 1;
}
}
notches
}
}
fn severity_for_sag(retained_v: f64) -> PqSeverity {
if retained_v < 0.1 {
PqSeverity::Critical
} else if retained_v < 0.5 {
PqSeverity::Severe
} else if retained_v < 0.8 {
PqSeverity::Moderate
} else {
PqSeverity::Minor
}
}
fn severity_for_swell(peak_v: f64) -> PqSeverity {
if peak_v > 1.8 {
PqSeverity::Critical
} else if peak_v > 1.4 {
PqSeverity::Severe
} else if peak_v > 1.2 {
PqSeverity::Moderate
} else {
PqSeverity::Minor
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::PI;
fn pure_sine(fs: f64, f0: f64, n_cycles: f64) -> Vec<f64> {
let n = (fs / f0 * n_cycles) as usize;
(0..n)
.map(|i| (2.0 * PI * f0 * i as f64 / fs).sin())
.collect()
}
fn with_sag(base: &[f64], sag_start: usize, sag_end: usize, sag_factor: f64) -> Vec<f64> {
base.iter()
.enumerate()
.map(|(i, &v)| {
if i >= sag_start && i < sag_end {
v * sag_factor
} else {
v
}
})
.collect()
}
#[test]
fn test_classify_events_sag() {
let fs = 10_000.0_f64;
let f0 = 50.0_f64;
let n_cycle = (fs / f0) as usize;
let base = pure_sine(fs, f0, 50.0);
let wave = with_sag(&base, 20 * n_cycle, 30 * n_cycle, 0.7);
let clf = PqEventClassifier::new(fs, f0);
let events = clf.classify_events(&wave);
let sag_count = events
.iter()
.filter(|e| matches!(e.event_class, PqEventClass::VoltageSag(_)))
.count();
assert!(
sag_count >= 1,
"Expected at least one sag, got events: {:?}",
events.len()
);
}
#[test]
fn test_classify_events_empty_waveform() {
let clf = PqEventClassifier::new(10_000.0, 50.0);
let events = clf.classify_events(&[]);
assert!(events.is_empty());
}
#[test]
fn test_detect_transients_peak() {
let fs = 10_000.0_f64;
let f0 = 50.0_f64;
let mut wave = pure_sine(fs, f0, 10.0);
let spike_idx = 1000;
wave[spike_idx] = 2.0;
let clf = PqEventClassifier::new(fs, f0);
let transients = clf.detect_transients(&wave, 32);
let found = transients.iter().any(|(s, _)| *s == spike_idx);
assert!(found, "Should detect spike at index {spike_idx}");
}
#[test]
fn test_detect_transients_no_false_positive_pure_sine() {
let fs = 10_000.0_f64;
let f0 = 50.0_f64;
let wave = pure_sine(fs, f0, 5.0);
let clf = PqEventClassifier::new(fs, f0);
let transients = clf.detect_transients(&wave, 32);
assert!(
transients.is_empty(),
"Pure sine should not produce transients, got {}: {:?}",
transients.len(),
transients
);
}
#[test]
fn test_event_summary_counts() {
let events = vec![
PqEvent {
start_sample: 0,
end_sample: 100,
event_class: PqEventClass::VoltageSag(VoltageEventType::InstantaneousSag),
severity: PqSeverity::Moderate,
rms_impact: 0.3,
frequency_impact: 0.0,
},
PqEvent {
start_sample: 500,
end_sample: 600,
event_class: PqEventClass::Transient,
severity: PqSeverity::Severe,
rms_impact: 0.8,
frequency_impact: 0.0,
},
PqEvent {
start_sample: 1000,
end_sample: 1100,
event_class: PqEventClass::VoltageSag(VoltageEventType::MomentarySag),
severity: PqSeverity::Severe,
rms_impact: 0.5,
frequency_impact: 0.0,
},
];
let summary = PqEventClassifier::event_summary(&events, 1.0, 1.0_f64);
assert_eq!(summary.total_events, 3);
assert_eq!(summary.events_per_hour, 3.0);
let sag_count = summary
.events_per_class
.iter()
.find(|(name, _)| name == "VoltageSag")
.map(|(_, c)| *c)
.unwrap_or(0);
assert_eq!(sag_count, 2, "Expected 2 sag events in summary");
let transient_count = summary
.events_per_class
.iter()
.find(|(name, _)| name == "Transient")
.map(|(_, c)| *c)
.unwrap_or(0);
assert_eq!(transient_count, 1);
}
#[test]
fn test_severity_ordering() {
assert!(PqSeverity::Minor < PqSeverity::Moderate);
assert!(PqSeverity::Moderate < PqSeverity::Severe);
assert!(PqSeverity::Severe < PqSeverity::Critical);
}
#[test]
fn test_classify_severity_interruption() {
let clf = PqEventClassifier::new(10_000.0, 50.0);
let ev = PqEvent {
start_sample: 0,
end_sample: 1000,
event_class: PqEventClass::Interruption,
severity: PqSeverity::Critical,
rms_impact: 0.95,
frequency_impact: 0.0,
};
assert_eq!(clf.classify_severity(&ev), PqSeverity::Critical);
}
#[test]
fn test_event_summary_most_severe() {
let events = vec![
PqEvent {
start_sample: 0,
end_sample: 10,
event_class: PqEventClass::Notch,
severity: PqSeverity::Minor,
rms_impact: 0.05,
frequency_impact: 0.0,
},
PqEvent {
start_sample: 100,
end_sample: 200,
event_class: PqEventClass::Interruption,
severity: PqSeverity::Critical,
rms_impact: 0.95,
frequency_impact: 0.0,
},
];
let summary = PqEventClassifier::event_summary(&events, 2.0, 1.0_f64);
let ms = summary.most_severe.expect("Should have most_severe");
assert_eq!(ms.severity, PqSeverity::Critical);
}
#[test]
fn test_extract_harmonics_used_in_harmonic_event() {
use crate::powerquality::waveform::extract_harmonics;
let fs = 10_000.0_f64;
let f0 = 50.0_f64;
let n = (fs / f0 * 4.0) as usize;
let wave: Vec<f64> = (0..n)
.map(|i| {
(2.0 * PI * f0 * i as f64 / fs).sin()
+ 0.1 * (2.0 * PI * 3.0 * f0 * i as f64 / fs).sin()
})
.collect();
let harmonics = extract_harmonics(&wave, fs, f0, 5);
assert!(!harmonics.is_empty());
}
#[test]
fn test_event_summary_sample_rate_scaling() {
let event = PqEvent {
start_sample: 0,
end_sample: 99, event_class: PqEventClass::VoltageSag(VoltageEventType::InstantaneousSag),
severity: PqSeverity::Moderate,
rms_impact: 0.2,
frequency_impact: 0.0,
};
let summary = PqEventClassifier::event_summary(&[event], 1.0, 25_600.0);
let expected = 100.0 / 25_600.0;
assert!(
(summary.cumulative_duration_s - expected).abs() < 1e-9,
"got {}, expected {}",
summary.cumulative_duration_s,
expected
);
}
#[test]
fn test_classify_events_swell() {
let fs = 10_000.0_f64;
let f0 = 50.0_f64;
let samples_per_cycle = (fs / f0) as usize; let sqrt2 = std::f64::consts::SQRT_2;
let nominal_peak = sqrt2; let swell_peak = 1.3 * sqrt2; let n_total = 45 * samples_per_cycle;
let swell_start = 20 * samples_per_cycle;
let swell_end = 25 * samples_per_cycle;
let wave: Vec<f64> = (0..n_total)
.map(|i| {
let amp = if i >= swell_start && i < swell_end {
swell_peak
} else {
nominal_peak
};
amp * (2.0 * PI * f0 * i as f64 / fs).sin()
})
.collect();
let clf = PqEventClassifier::new(fs, f0);
let events = clf.classify_events(&wave);
assert!(
events
.iter()
.any(|e| matches!(e.event_class, PqEventClass::VoltageSwell(_))),
"Expected at least one VoltageSwell event, got: {:?}",
events
);
}
#[test]
fn test_classify_events_interruption() {
let fs = 10_000.0_f64;
let f0 = 50.0_f64;
let samples_per_cycle = (fs / f0) as usize; let n_total = 55 * samples_per_cycle;
let interr_start = 20 * samples_per_cycle;
let interr_end = 35 * samples_per_cycle;
let wave: Vec<f64> = (0..n_total)
.map(|i| {
let amp = if i >= interr_start && i < interr_end {
0.05
} else {
1.0
};
amp * (2.0 * PI * f0 * i as f64 / fs).sin()
})
.collect();
let clf = PqEventClassifier::new(fs, f0);
let events = clf.classify_events(&wave);
assert!(
events
.iter()
.any(|e| e.event_class == PqEventClass::Interruption),
"Expected at least one Interruption event, got: {:?}",
events
);
}
#[test]
fn test_event_duration_category() {
let clf = PqEventClassifier::new(10_000.0, 50.0);
let ev_minor = PqEvent {
start_sample: 0,
end_sample: 10,
event_class: PqEventClass::VoltageSag(VoltageEventType::InstantaneousSag),
severity: PqSeverity::Minor,
rms_impact: 0.1,
frequency_impact: 0.0,
};
assert_eq!(
clf.classify_severity(&ev_minor),
PqSeverity::Minor,
"rms_impact=0.1 for InstantaneousSag should be Minor"
);
let ev_critical = PqEvent {
start_sample: 0,
end_sample: 200,
event_class: PqEventClass::VoltageSag(VoltageEventType::MomentarySag),
severity: PqSeverity::Minor,
rms_impact: 0.6,
frequency_impact: 0.0,
};
assert_eq!(
clf.classify_severity(&ev_critical),
PqSeverity::Critical,
"rms_impact=0.6 for MomentarySag should be Critical"
);
}
#[test]
fn test_transient_severity_critical() {
let fs = 10_000.0_f64;
let f0 = 50.0_f64;
let n = (3.0 * fs / f0) as usize; let mut wave: Vec<f64> = (0..n)
.map(|i| (2.0 * PI * f0 * i as f64 / fs).sin())
.collect();
wave[500] = 2.5;
let clf = PqEventClassifier::new(fs, f0);
let events = clf.classify_events(&wave);
let has_critical_transient = events.iter().any(|e| {
e.event_class == PqEventClass::Transient && e.severity == PqSeverity::Critical
});
assert!(
has_critical_transient,
"Expected a Critical Transient for 2.5 pu spike, got: {:?}",
events
);
}
#[test]
fn test_classify_severity_swell_moderate() {
let clf = PqEventClassifier::new(10_000.0, 50.0);
let ev = PqEvent {
start_sample: 0,
end_sample: 100,
event_class: PqEventClass::VoltageSwell(VoltageEventType::InstantaneousSwell),
severity: PqSeverity::Minor,
rms_impact: 0.25,
frequency_impact: 0.0,
};
assert_eq!(
clf.classify_severity(&ev),
PqSeverity::Moderate,
"rms_impact=0.25 for VoltageSwell should be Moderate"
);
}
}