use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum VoltageEventType {
InstantaneousSag,
MomentarySag,
TemporarySag,
InstantaneousSwell,
MomentarySwell,
TemporarySwell,
Interruption,
Undervoltage,
Overvoltage,
Normal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VoltageEvent {
pub event_type: VoltageEventType,
pub start_sample: usize,
pub end_sample: usize,
pub duration_cycles: f64,
pub magnitude_pu: f64,
pub retained_voltage: f64,
pub energy_absorbed: f64,
}
pub fn half_cycle_rms(waveform: &[f64], sample_rate_hz: f64, nominal_freq_hz: f64) -> Vec<f64> {
if waveform.is_empty() || sample_rate_hz <= 0.0 || nominal_freq_hz <= 0.0 {
return vec![];
}
let half_cycle_samples = (sample_rate_hz / (2.0 * nominal_freq_hz)).round() as usize;
if half_cycle_samples == 0 {
return vec![];
}
let n_windows = waveform.len() / half_cycle_samples;
let mut rms_envelope = Vec::with_capacity(n_windows);
for w in 0..n_windows {
let start = w * half_cycle_samples;
let end = start + half_cycle_samples;
let window = &waveform[start..end];
let mean_sq = window.iter().map(|&v| v * v).sum::<f64>() / half_cycle_samples as f64;
rms_envelope.push(mean_sq.sqrt());
}
rms_envelope
}
pub fn detect_voltage_events(
v_rms_pu: &[f64],
sample_rate_hz: f64,
nominal_freq_hz: f64,
threshold_sag: f64,
threshold_swell: f64,
threshold_interruption: f64,
) -> Vec<VoltageEvent> {
if v_rms_pu.is_empty() || sample_rate_hz <= 0.0 || nominal_freq_hz <= 0.0 {
return vec![];
}
let half_cycle_samples = (sample_rate_hz / (2.0 * nominal_freq_hz)).round() as usize;
let half_cycle_s = 1.0 / (2.0 * nominal_freq_hz);
let mut events = Vec::new();
let mut i = 0;
while i < v_rms_pu.len() {
let v = v_rms_pu[i];
let is_sag = v < threshold_sag;
let is_swell = v > threshold_swell;
if !is_sag && !is_swell {
i += 1;
continue;
}
let start = i;
let mut char_v = v;
while i < v_rms_pu.len() {
let vi = v_rms_pu[i];
let still_anomaly = if is_sag {
vi < threshold_sag
} else {
vi > threshold_swell
};
if !still_anomaly {
break;
}
if is_sag {
if vi < char_v {
char_v = vi;
}
} else {
if vi > char_v {
char_v = vi;
}
}
i += 1;
}
let end = i - 1; let n_env_samples = end - start + 1;
let duration_s = n_env_samples as f64 * half_cycle_s;
let duration_cycles = n_env_samples as f64 * 0.5;
let energy_absorbed: f64 = v_rms_pu[start..=end]
.iter()
.map(|&vi| {
let vi_clamped = vi.clamp(0.0, 1.0);
(1.0 - vi_clamped * vi_clamped) * half_cycle_s
})
.sum();
let event_type = if is_sag {
classify_sag(char_v, duration_s, duration_cycles, threshold_interruption)
} else {
classify_swell(char_v, duration_s, duration_cycles)
};
let start_sample = start * half_cycle_samples;
let end_sample = (end + 1) * half_cycle_samples - 1;
events.push(VoltageEvent {
event_type,
start_sample,
end_sample,
duration_cycles,
magnitude_pu: char_v,
retained_voltage: char_v,
energy_absorbed,
});
}
events
}
fn classify_sag(
min_v: f64,
duration_s: f64,
duration_cycles: f64,
threshold_interruption: f64,
) -> VoltageEventType {
if min_v < threshold_interruption {
return VoltageEventType::Interruption;
}
const INSTANTANEOUS_MAX_CYCLES: f64 = 30.0;
const MOMENTARY_MAX_S: f64 = 3.0;
const TEMPORARY_MAX_S: f64 = 60.0;
if duration_cycles <= 0.5 {
VoltageEventType::InstantaneousSag
} else if duration_cycles <= INSTANTANEOUS_MAX_CYCLES {
VoltageEventType::InstantaneousSag
} else if duration_s <= MOMENTARY_MAX_S {
VoltageEventType::MomentarySag
} else if duration_s <= TEMPORARY_MAX_S {
VoltageEventType::TemporarySag
} else {
if (0.8..=0.9).contains(&min_v) {
VoltageEventType::Undervoltage
} else {
VoltageEventType::TemporarySag
}
}
}
fn classify_swell(max_v: f64, duration_s: f64, duration_cycles: f64) -> VoltageEventType {
const INSTANTANEOUS_MAX_CYCLES: f64 = 30.0;
const MOMENTARY_MAX_S: f64 = 3.0;
const TEMPORARY_MAX_S: f64 = 60.0;
if duration_cycles <= INSTANTANEOUS_MAX_CYCLES {
VoltageEventType::InstantaneousSwell
} else if duration_s <= MOMENTARY_MAX_S {
VoltageEventType::MomentarySwell
} else if duration_s <= TEMPORARY_MAX_S {
VoltageEventType::TemporarySwell
} else {
if (1.1..=1.2).contains(&max_v) {
VoltageEventType::Overvoltage
} else {
VoltageEventType::TemporarySwell
}
}
}
pub fn itic_compatible(event: &VoltageEvent, nominal_freq_hz: f64) -> bool {
let duration_ms = event.duration_cycles / nominal_freq_hz * 1000.0;
let v = event.retained_voltage;
let upper = itic_upper_limit(duration_ms);
let lower = itic_lower_limit(duration_ms);
v >= lower && v <= upper
}
fn itic_upper_limit(duration_ms: f64) -> f64 {
if duration_ms <= 1.0 {
f64::INFINITY } else if duration_ms <= 3.0 {
2.0
} else if duration_ms <= 20.0 {
2.0 - (duration_ms - 3.0) / (20.0 - 3.0) * (2.0 - 1.4)
} else if duration_ms <= 500.0 {
1.4 - (duration_ms - 20.0) / (500.0 - 20.0) * (1.4 - 1.2)
} else {
1.1
}
}
fn itic_lower_limit(duration_ms: f64) -> f64 {
let half_cycle_60hz_ms = 1000.0 / (2.0 * 60.0); if duration_ms <= half_cycle_60hz_ms {
0.0
} else if duration_ms <= 20.0 {
0.7 * (duration_ms - half_cycle_60hz_ms) / (20.0 - half_cycle_60hz_ms)
} else if duration_ms <= 500.0 {
0.7
} else if duration_ms <= 10_000.0 {
0.5
} else {
0.9
}
}
pub fn semi_f47_compatible(event: &VoltageEvent, nominal_freq_hz: f64) -> bool {
let duration_s = event.duration_cycles / nominal_freq_hz;
let v = event.retained_voltage;
if duration_s > 10.0 {
return false;
}
let min_v = semi_f47_min_voltage(duration_s);
v >= min_v
}
fn semi_f47_min_voltage(duration_s: f64) -> f64 {
let duration_ms = duration_s * 1000.0;
if duration_ms <= 20.0 {
0.0
} else if duration_ms <= 200.0 {
0.50
} else if duration_ms <= 500.0 {
0.70
} else {
0.80
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::PI;
fn pure_sine_pu(n_samples: usize, sample_rate_hz: f64, nominal_hz: f64) -> Vec<f64> {
(0..n_samples)
.map(|i| (2.0 * PI * nominal_hz * i as f64 / sample_rate_hz).sin())
.collect()
}
#[test]
fn test_half_cycle_rms_sinusoid() {
let fs = 10_000.0_f64;
let f0 = 50.0_f64;
let n = (fs * 10.0 / f0) as usize; let wave = pure_sine_pu(n, fs, f0);
let rms = half_cycle_rms(&wave, fs, f0);
assert!(!rms.is_empty(), "RMS envelope must not be empty");
for &r in rms.iter().skip(1) {
assert!(
(r - 1.0_f64 / 2.0_f64.sqrt()).abs() < 0.01,
"half-cycle RMS should be ~0.7071, got {r:.4}"
);
}
}
#[test]
fn test_half_cycle_rms_empty() {
assert!(half_cycle_rms(&[], 1000.0, 50.0).is_empty());
}
#[test]
fn test_sag_detection_momentary() {
let mut env = vec![1.0_f64; 20];
env.extend(vec![0.7; 70]); env.extend(vec![1.0; 20]);
let events = detect_voltage_events(&env, 10_000.0, 50.0, 0.9, 1.1, 0.1);
assert_eq!(events.len(), 1, "Expected exactly one sag event");
let ev = &events[0];
assert_eq!(
ev.event_type,
VoltageEventType::MomentarySag,
"Expected MomentarySag, got {:?}",
ev.event_type
);
assert!(
(ev.magnitude_pu - 0.7).abs() < 1e-9,
"magnitude should be 0.7"
);
}
#[test]
fn test_swell_detection() {
let mut env = vec![1.0_f64; 20];
env.extend(vec![1.25; 50]); env.extend(vec![1.0; 20]);
let events = detect_voltage_events(&env, 10_000.0, 50.0, 0.9, 1.1, 0.1);
assert_eq!(events.len(), 1);
let ev = &events[0];
assert_eq!(
ev.event_type,
VoltageEventType::InstantaneousSwell,
"Expected InstantaneousSwell, got {:?}",
ev.event_type
);
assert!((ev.magnitude_pu - 1.25).abs() < 1e-9);
}
#[test]
fn test_interruption_detection() {
let mut env = vec![1.0_f64; 20];
env.extend(vec![0.05; 40]); env.extend(vec![1.0; 20]);
let events = detect_voltage_events(&env, 10_000.0, 50.0, 0.9, 1.1, 0.1);
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, VoltageEventType::Interruption);
}
#[test]
fn test_itic_normal_voltage() {
let event = VoltageEvent {
event_type: VoltageEventType::Normal,
start_sample: 0,
end_sample: 1000,
duration_cycles: 100.0,
magnitude_pu: 1.0,
retained_voltage: 1.0,
energy_absorbed: 0.0,
};
assert!(itic_compatible(&event, 50.0));
}
#[test]
fn test_itic_deep_short_sag_acceptable() {
let event = VoltageEvent {
event_type: VoltageEventType::InstantaneousSag,
start_sample: 0,
end_sample: 50,
duration_cycles: 0.5,
magnitude_pu: 0.0,
retained_voltage: 0.0,
energy_absorbed: 0.01,
};
assert!(
itic_compatible(&event, 60.0),
"Sub-cycle zero-volt sag should be ITIC acceptable"
);
}
#[test]
fn test_semi_f47_deep_short_sag() {
let event = VoltageEvent {
event_type: VoltageEventType::InstantaneousSag,
start_sample: 0,
end_sample: 100,
duration_cycles: 0.5,
magnitude_pu: 0.0,
retained_voltage: 0.0,
energy_absorbed: 0.01,
};
assert!(semi_f47_compatible(&event, 50.0));
}
#[test]
fn test_semi_f47_fails_long_deep_sag() {
let event = VoltageEvent {
event_type: VoltageEventType::TemporarySag,
start_sample: 0,
end_sample: 15000,
duration_cycles: 15.0,
magnitude_pu: 0.4,
retained_voltage: 0.4,
energy_absorbed: 0.5,
};
assert!(!semi_f47_compatible(&event, 50.0));
}
#[test]
fn test_energy_absorbed_non_negative() {
let mut env = vec![1.0_f64; 10];
env.extend(vec![0.6; 30]);
env.extend(vec![1.0; 10]);
let events = detect_voltage_events(&env, 10_000.0, 50.0, 0.9, 1.1, 0.1);
for ev in &events {
assert!(
ev.energy_absorbed >= 0.0,
"Energy absorbed must be non-negative"
);
}
}
}