use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlickermeterConfig {
pub v_nominal: f64,
pub f_nominal: f64,
pub fs: f64,
pub t_short_s: f64,
pub n_pst_for_plt: usize,
}
impl Default for FlickermeterConfig {
fn default() -> Self {
Self {
v_nominal: 230.0,
f_nominal: 50.0,
fs: 1600.0, t_short_s: 600.0,
n_pst_for_plt: 12,
}
}
}
impl FlickermeterConfig {
pub fn hz_60() -> Self {
Self {
v_nominal: 120.0,
f_nominal: 60.0,
fs: 1920.0,
t_short_s: 600.0,
n_pst_for_plt: 12,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PstResult {
pub p_st: f64,
pub p_levels: [f64; 5],
pub duration_s: f64,
pub iec_compliant: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PltResult {
pub p_lt: f64,
pub p_st_values: Vec<f64>,
pub iec_compliant: bool,
}
pub fn compute_pst(flicker_sensation: &[f64]) -> PstResult {
if flicker_sensation.is_empty() {
return PstResult {
p_st: 0.0,
p_levels: [0.0; 5],
duration_s: 0.0,
iec_compliant: true,
};
}
let n = flicker_sensation.len();
let mut squared: Vec<f64> = flicker_sensation.iter().map(|&s| s * s).collect();
squared.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let percentile = |p: f64| -> f64 {
let idx = ((1.0 - p / 100.0) * n as f64) as usize;
let idx = idx.min(n - 1);
squared[idx]
};
let p_levels = [
percentile(0.1),
percentile(1.0),
percentile(3.0),
percentile(10.0),
percentile(50.0),
];
let p_st = (0.0314 * p_levels[0]
+ 0.0525 * p_levels[1]
+ 0.0657 * p_levels[2]
+ 0.2800 * p_levels[3]
+ 0.0800 * p_levels[4])
.sqrt();
PstResult {
p_st,
p_levels,
duration_s: n as f64,
iec_compliant: p_st <= 1.0,
}
}
pub fn compute_plt(p_st_values: &[f64]) -> PltResult {
if p_st_values.is_empty() {
return PltResult {
p_lt: 0.0,
p_st_values: vec![],
iec_compliant: true,
};
}
let n = p_st_values.len() as f64;
let cube_mean = p_st_values.iter().map(|&p| p * p * p).sum::<f64>() / n;
let p_lt = cube_mean.cbrt();
PltResult {
p_lt,
p_st_values: p_st_values.to_vec(),
iec_compliant: p_lt <= 0.65,
}
}
#[derive(Debug, Clone)]
struct Iir1 {
a1: f64,
b0: f64,
y_prev: f64,
}
impl Iir1 {
fn new_lp(fc: f64, fs: f64) -> Self {
let wc = 2.0 * std::f64::consts::PI * fc / fs;
let alpha = wc / (wc + 1.0);
Self {
a1: 1.0 - alpha,
b0: alpha,
y_prev: 0.0,
}
}
fn step(&mut self, x: f64) -> f64 {
let y = self.b0 * x + self.a1 * self.y_prev;
self.y_prev = y;
y
}
}
pub fn flickermeter_chain(voltage_samples: &[f64], config: &FlickermeterConfig) -> Vec<f64> {
if voltage_samples.is_empty() {
return vec![];
}
let v_ref = config.v_nominal * std::f64::consts::SQRT_2; let fs = config.fs;
let normalised: Vec<f64> = voltage_samples.iter().map(|&v| v / v_ref).collect();
let squared: Vec<f64> = normalised.iter().map(|&v| v * v).collect();
let mut lp_demod = Iir1::new_lp(config.f_nominal * 0.6, fs);
let envelope: Vec<f64> = squared.iter().map(|&x| lp_demod.step(x)).collect();
let mean_env = envelope.iter().sum::<f64>() / envelope.len() as f64;
let modulation: Vec<f64> = envelope.iter().map(|&e| e - mean_env).collect();
let mut lp_weight = Iir1::new_lp(8.8, fs); let weighted: Vec<f64> = modulation.iter().map(|&m| lp_weight.step(m)).collect();
let mut lp_sense = Iir1::new_lp(0.1, fs);
weighted.iter().map(|&w| lp_sense.step(w * w)).collect()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct En50160Report {
pub mean_voltage_deviation_pu: f64,
pub voltage_within_10pct_fraction: f64,
pub mean_frequency_hz: f64,
pub frequency_within_band_fraction: f64,
pub thd_p95: f64,
pub unbalance_p95: f64,
pub dips_per_week: f64,
pub short_interruptions_per_year: f64,
pub compliant: bool,
pub violations: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct En50160Data {
pub v_rms_10min: Vec<f64>,
pub frequency_10s: Vec<f64>,
pub thd_10min: Vec<f64>,
pub unbalance_10min: Vec<f64>,
pub n_dips: usize,
pub n_short_interruptions: usize,
pub observation_weeks: f64,
pub v_nominal: f64,
pub f_nominal: f64,
}
impl En50160Data {
pub fn new_50hz(v_nominal: f64) -> Self {
Self {
v_rms_10min: vec![],
frequency_10s: vec![],
thd_10min: vec![],
unbalance_10min: vec![],
n_dips: 0,
n_short_interruptions: 0,
observation_weeks: 1.0,
v_nominal,
f_nominal: 50.0,
}
}
}
pub fn check_en50160(data: &En50160Data) -> En50160Report {
let v_nom = data.v_nominal;
let f_nom = data.f_nominal;
let mut violations = Vec::new();
let v_within: f64 = if data.v_rms_10min.is_empty() {
1.0
} else {
data.v_rms_10min
.iter()
.filter(|&&v| (v - v_nom).abs() / v_nom <= 0.10)
.count() as f64
/ data.v_rms_10min.len() as f64
};
if v_within < 0.95 {
violations.push(format!(
"Voltage: only {:.1}% within ±10% (limit 95%)",
v_within * 100.0
));
}
let mean_v_dev = if data.v_rms_10min.is_empty() {
0.0
} else {
data.v_rms_10min
.iter()
.map(|&v| (v - v_nom).abs() / v_nom)
.sum::<f64>()
/ data.v_rms_10min.len() as f64
};
let freq_band = 0.5; let f_within: f64 = if data.frequency_10s.is_empty() {
1.0
} else {
data.frequency_10s
.iter()
.filter(|&&f| (f - f_nom).abs() <= freq_band)
.count() as f64
/ data.frequency_10s.len() as f64
};
let mean_freq = if data.frequency_10s.is_empty() {
f_nom
} else {
data.frequency_10s.iter().sum::<f64>() / data.frequency_10s.len() as f64
};
if f_within < 0.95 {
violations.push(format!(
"Frequency: only {:.1}% within ±0.5 Hz (limit 95%)",
f_within * 100.0
));
}
let thd_p95 = percentile_95(&data.thd_10min);
if thd_p95 > 0.08 {
violations.push(format!("THD: P95={:.1}% exceeds 8% limit", thd_p95 * 100.0));
}
let unbal_p95 = percentile_95(&data.unbalance_10min);
if unbal_p95 > 0.02 {
violations.push(format!(
"Unbalance: P95={:.2}% exceeds 2% limit",
unbal_p95 * 100.0
));
}
let dips_per_week = data.n_dips as f64 / data.observation_weeks.max(1e-6);
let interruptions_per_year =
data.n_short_interruptions as f64 / data.observation_weeks.max(1e-6) * 52.18;
if interruptions_per_year > 250.0 {
violations.push(format!(
"Short interruptions: {:.0}/year exceeds 250/year",
interruptions_per_year
));
}
En50160Report {
mean_voltage_deviation_pu: mean_v_dev,
voltage_within_10pct_fraction: v_within,
mean_frequency_hz: mean_freq,
frequency_within_band_fraction: f_within,
thd_p95,
unbalance_p95: unbal_p95,
dips_per_week,
short_interruptions_per_year: interruptions_per_year,
compliant: violations.is_empty(),
violations,
}
}
fn percentile_95(data: &[f64]) -> f64 {
if data.is_empty() {
return 0.0;
}
let mut sorted = data.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let idx = ((0.95 * sorted.len() as f64) as usize).min(sorted.len() - 1);
sorted[idx]
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum VoltageEventClass {
Dip { depth_pu: f64, duration_ms: f64 },
Swell { height_pu: f64, duration_ms: f64 },
ShortInterruption { duration_ms: f64 },
LongInterruption { duration_min: f64 },
Normal,
}
pub fn detect_voltage_events(
v_rms_10ms: &[f64],
v_nominal: f64,
dt_ms: f64,
) -> Vec<(f64, VoltageEventClass)> {
let dip_threshold = 0.90 * v_nominal;
let swell_threshold = 1.10 * v_nominal;
let interruption_threshold = 0.01 * v_nominal;
let mut events = Vec::new();
let mut i = 0;
while i < v_rms_10ms.len() {
let v = v_rms_10ms[i];
let t_ms = i as f64 * dt_ms;
if v < interruption_threshold {
let start = i;
while i < v_rms_10ms.len() && v_rms_10ms[i] < interruption_threshold {
i += 1;
}
let dur_ms = (i - start) as f64 * dt_ms;
if dur_ms < 3.0 * 60.0 * 1000.0 {
events.push((
t_ms,
VoltageEventClass::ShortInterruption {
duration_ms: dur_ms,
},
));
} else {
events.push((
t_ms,
VoltageEventClass::LongInterruption {
duration_min: dur_ms / 60_000.0,
},
));
}
} else if v < dip_threshold {
let start = i;
let v_min = {
let mut vmin = v;
while i < v_rms_10ms.len() && v_rms_10ms[i] < dip_threshold {
vmin = vmin.min(v_rms_10ms[i]);
i += 1;
}
vmin
};
let dur_ms = (i - start) as f64 * dt_ms;
let depth = 1.0 - v_min / v_nominal;
events.push((
t_ms,
VoltageEventClass::Dip {
depth_pu: depth,
duration_ms: dur_ms,
},
));
} else if v > swell_threshold {
let start = i;
let v_max = {
let mut vmax = v;
while i < v_rms_10ms.len() && v_rms_10ms[i] > swell_threshold {
vmax = vmax.max(v_rms_10ms[i]);
i += 1;
}
vmax
};
let dur_ms = (i - start) as f64 * dt_ms;
let height = v_max / v_nominal - 1.0;
events.push((
t_ms,
VoltageEventClass::Swell {
height_pu: height,
duration_ms: dur_ms,
},
));
} else {
i += 1;
}
}
events
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SarfiResult {
pub sarfi_90: f64,
pub sarfi_70: f64,
pub sarfi_50: f64,
pub n_dips: usize,
pub n_swells: usize,
pub n_interruptions: usize,
}
pub fn sarfi_statistics(events: &[(f64, VoltageEventClass)]) -> SarfiResult {
let n_dips = events
.iter()
.filter(|(_, e)| matches!(e, VoltageEventClass::Dip { .. }))
.count();
let n_swells = events
.iter()
.filter(|(_, e)| matches!(e, VoltageEventClass::Swell { .. }))
.count();
let n_int = events
.iter()
.filter(|(_, e)| {
matches!(
e,
VoltageEventClass::ShortInterruption { .. }
| VoltageEventClass::LongInterruption { .. }
)
})
.count();
let sarfi_90 = events
.iter()
.filter(|(_, e)| match e {
VoltageEventClass::Dip { depth_pu, .. } => *depth_pu >= 0.10,
VoltageEventClass::ShortInterruption { .. }
| VoltageEventClass::LongInterruption { .. } => true,
_ => false,
})
.count() as f64;
let sarfi_70 = events
.iter()
.filter(|(_, e)| match e {
VoltageEventClass::Dip { depth_pu, .. } => *depth_pu >= 0.30,
VoltageEventClass::ShortInterruption { .. }
| VoltageEventClass::LongInterruption { .. } => true,
_ => false,
})
.count() as f64;
let sarfi_50 = events
.iter()
.filter(|(_, e)| match e {
VoltageEventClass::Dip { depth_pu, .. } => *depth_pu >= 0.50,
VoltageEventClass::ShortInterruption { .. }
| VoltageEventClass::LongInterruption { .. } => true,
_ => false,
})
.count() as f64;
SarfiResult {
sarfi_90,
sarfi_70,
sarfi_50,
n_dips,
n_swells,
n_interruptions: n_int,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sine_wave(freq: f64, amp: f64, duration_s: f64, fs: f64) -> Vec<f64> {
let n = (duration_s * fs) as usize;
(0..n)
.map(|i| amp * (2.0 * std::f64::consts::PI * freq * i as f64 / fs).sin())
.collect()
}
#[test]
fn test_compute_pst_quiet_signal_low() {
let v = sine_wave(50.0, 230.0 * std::f64::consts::SQRT_2, 10.0, 1600.0);
let config = FlickermeterConfig::default();
let sensation = flickermeter_chain(&v, &config);
let pst = compute_pst(&sensation);
assert!(
pst.p_st < 0.5,
"Clean signal should have low flicker: {:.4}",
pst.p_st
);
}
#[test]
fn test_compute_pst_empty() {
let pst = compute_pst(&[]);
assert_eq!(pst.p_st, 0.0);
assert!(pst.iec_compliant);
}
#[test]
fn test_compute_plt_basic() {
let p_st_values = vec![0.3, 0.4, 0.5, 0.3, 0.4, 0.5, 0.3, 0.4, 0.5, 0.3, 0.4, 0.5];
let result = compute_plt(&p_st_values);
assert!(result.p_lt > 0.0, "P_lt should be positive");
let expected = (0.3_f64.powi(3) + 0.4_f64.powi(3) + 0.5_f64.powi(3)) / 3.0;
let expected_plt = expected.cbrt();
assert!(
(result.p_lt - expected_plt).abs() < 0.01,
"P_lt should match analytical: {:.4} vs {:.4}",
result.p_lt,
expected_plt
);
}
#[test]
fn test_compute_plt_empty() {
let result = compute_plt(&[]);
assert_eq!(result.p_lt, 0.0);
assert!(result.iec_compliant);
}
#[test]
fn test_plt_compliant_for_low_pst() {
let p_st = vec![0.3; 12];
let result = compute_plt(&p_st);
assert!(
result.iec_compliant,
"P_lt from P_st=0.3 should be compliant: {:.4}",
result.p_lt
);
}
#[test]
fn test_plt_noncompliant_for_high_pst() {
let p_st = vec![1.5; 12]; let result = compute_plt(&p_st);
assert!(!result.iec_compliant);
}
#[test]
fn test_flickermeter_chain_length() {
let v = sine_wave(50.0, 325.3, 1.0, 1600.0);
let config = FlickermeterConfig::default();
let sensation = flickermeter_chain(&v, &config);
assert_eq!(sensation.len(), v.len());
}
#[test]
fn test_flickermeter_chain_output_bounded() {
let v = sine_wave(50.0, 325.3, 2.0, 1600.0);
let config = FlickermeterConfig::default();
let sensation = flickermeter_chain(&v, &config);
for &s in &sensation {
assert!(s.is_finite(), "All sensation values should be finite");
}
}
#[test]
fn test_en50160_compliant_data() {
let mut data = En50160Data::new_50hz(230.0);
data.v_rms_10min = (0..1000)
.map(|i| 230.0 + (i as f64 % 20.0 - 10.0) * 0.5)
.collect();
data.frequency_10s = vec![50.0; 5000];
data.thd_10min = vec![0.03; 1000]; data.unbalance_10min = vec![0.01; 1000]; data.n_dips = 5;
data.n_short_interruptions = 2;
data.observation_weeks = 1.0;
let report = check_en50160(&data);
assert!(
report.compliant,
"Good data should be compliant: {:?}",
report.violations
);
}
#[test]
fn test_en50160_voltage_violation() {
let mut data = En50160Data::new_50hz(230.0);
data.v_rms_10min = (0..1000)
.map(|i| if i % 10 == 0 { 230.0 * 1.15 } else { 230.0 })
.collect();
data.frequency_10s = vec![50.0; 100];
data.thd_10min = vec![0.03; 100];
data.unbalance_10min = vec![0.01; 100];
data.observation_weeks = 1.0;
let report = check_en50160(&data);
assert!(!report.compliant);
assert!(report.violations.iter().any(|v| v.contains("Voltage")));
}
#[test]
fn test_en50160_thd_violation() {
let mut data = En50160Data::new_50hz(230.0);
data.v_rms_10min = vec![230.0; 1000];
data.frequency_10s = vec![50.0; 1000];
data.thd_10min = vec![0.10; 1000]; data.unbalance_10min = vec![0.01; 1000];
data.observation_weeks = 1.0;
let report = check_en50160(&data);
assert!(!report.compliant);
assert!(report.violations.iter().any(|v| v.contains("THD")));
}
#[test]
fn test_detect_voltage_dip() {
let mut v = vec![230.0_f64; 100];
for val in v.iter_mut().take(30).skip(20) {
*val = 230.0 * 0.75;
} let events = detect_voltage_events(&v, 230.0, 10.0);
let dips: Vec<_> = events
.iter()
.filter(|(_, e)| matches!(e, VoltageEventClass::Dip { .. }))
.collect();
assert!(!dips.is_empty(), "Should detect voltage dip");
}
#[test]
fn test_detect_voltage_swell() {
let mut v = vec![230.0_f64; 100];
for val in v.iter_mut().take(50).skip(40) {
*val = 230.0 * 1.20;
}
let events = detect_voltage_events(&v, 230.0, 10.0);
let swells: Vec<_> = events
.iter()
.filter(|(_, e)| matches!(e, VoltageEventClass::Swell { .. }))
.collect();
assert!(!swells.is_empty(), "Should detect swell");
}
#[test]
fn test_detect_short_interruption() {
let mut v = vec![230.0_f64; 200];
for val in v.iter_mut().take(70).skip(50) {
*val = 0.5;
} let events = detect_voltage_events(&v, 230.0, 10.0);
let ints: Vec<_> = events
.iter()
.filter(|(_, e)| matches!(e, VoltageEventClass::ShortInterruption { .. }))
.collect();
assert!(!ints.is_empty(), "Should detect short interruption");
}
#[test]
fn test_sarfi_statistics() {
let events = vec![
(
0.0,
VoltageEventClass::Dip {
depth_pu: 0.15,
duration_ms: 100.0,
},
),
(
500.0,
VoltageEventClass::Dip {
depth_pu: 0.35,
duration_ms: 50.0,
},
),
(
1000.0,
VoltageEventClass::Dip {
depth_pu: 0.55,
duration_ms: 200.0,
},
),
(
2000.0,
VoltageEventClass::Swell {
height_pu: 0.12,
duration_ms: 80.0,
},
),
];
let sarfi = sarfi_statistics(&events);
assert_eq!(sarfi.n_dips, 3);
assert_eq!(sarfi.n_swells, 1);
assert_eq!(sarfi.sarfi_90, 3.0); assert_eq!(sarfi.sarfi_70, 2.0); assert_eq!(sarfi.sarfi_50, 1.0); }
#[test]
fn test_pst_iec_compliance() {
let low_pst = PstResult {
p_st: 0.8,
p_levels: [0.0; 5],
duration_s: 600.0,
iec_compliant: true,
};
let high_pst = PstResult {
p_st: 1.2,
p_levels: [0.0; 5],
duration_s: 600.0,
iec_compliant: false,
};
assert!(low_pst.iec_compliant);
assert!(!high_pst.iec_compliant);
}
}