use rust_decimal::Decimal;
use time::OffsetDateTime;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct PowerQualityInterval {
pub from: OffsetDateTime,
pub to: OffsetDateTime,
pub voltage_l1_v: Option<Decimal>,
pub voltage_l2_v: Option<Decimal>,
pub voltage_l3_v: Option<Decimal>,
pub current_l1_a: Option<Decimal>,
pub current_l2_a: Option<Decimal>,
pub current_l3_a: Option<Decimal>,
pub frequency_hz: Option<Decimal>,
pub power_factor: Option<Decimal>,
pub thd_voltage_pct: Option<Decimal>,
pub thd_current_pct: Option<Decimal>,
}
impl PowerQualityInterval {
#[must_use]
pub const fn empty(from: OffsetDateTime, to: OffsetDateTime) -> Self {
Self {
from,
to,
voltage_l1_v: None,
voltage_l2_v: None,
voltage_l3_v: None,
current_l1_a: None,
current_l2_a: None,
current_l3_a: None,
frequency_hz: None,
power_factor: None,
thd_voltage_pct: None,
thd_current_pct: None,
}
}
pub fn phase_voltages(&self) -> impl Iterator<Item = Decimal> {
[self.voltage_l1_v, self.voltage_l2_v, self.voltage_l3_v]
.into_iter()
.flatten()
}
#[must_use]
pub fn voltage_out_of_range(&self, nominal_v: Decimal, threshold_pct: Decimal) -> bool {
if nominal_v.is_zero() {
return false;
}
self.phase_voltages()
.any(|v| ((v - nominal_v) / nominal_v * Decimal::ONE_HUNDRED).abs() > threshold_pct)
}
#[must_use]
pub fn frequency_out_of_range(&self, threshold_hz: Decimal) -> bool {
self.frequency_hz
.is_some_and(|f| (f - Decimal::from(50u32)).abs() > threshold_hz)
}
#[must_use]
pub fn power_factor_below(&self, min_pf: Decimal) -> bool {
self.power_factor.is_some_and(|pf| pf < min_pf)
}
#[must_use]
pub fn voltage_thd_exceeded(&self, max_thd_pct: Decimal) -> bool {
self.thd_voltage_pct.is_some_and(|thd| thd > max_thd_pct)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct En50160Limits {
pub nominal_voltage_v: Decimal,
pub voltage_band_pct: Decimal,
pub voltage_share_pct: f64,
pub voltage_absolute_upper_pct: Decimal,
pub voltage_absolute_lower_pct: Decimal,
pub frequency_band_pct: Decimal,
pub frequency_share_pct: f64,
pub thd_max_pct: Decimal,
pub thd_share_pct: f64,
}
impl En50160Limits {
pub const LOW_VOLTAGE: Self = Self {
nominal_voltage_v: Decimal::from_parts(230, 0, 0, false, 0),
voltage_band_pct: Decimal::from_parts(10, 0, 0, false, 0),
voltage_share_pct: 95.0,
voltage_absolute_upper_pct: Decimal::from_parts(10, 0, 0, false, 0),
voltage_absolute_lower_pct: Decimal::from_parts(15, 0, 0, false, 0),
frequency_band_pct: Decimal::from_parts(1, 0, 0, false, 0),
frequency_share_pct: 99.5,
thd_max_pct: Decimal::from_parts(8, 0, 0, false, 0),
thd_share_pct: 95.0,
};
}
impl Default for En50160Limits {
fn default() -> Self {
Self::LOW_VOLTAGE
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct LimitOutcome {
pub samples: usize,
pub within: usize,
pub share_pct: f64,
pub required_share_pct: f64,
pub compliant: bool,
pub worst: Option<Decimal>,
}
impl LimitOutcome {
fn new(within: usize, samples: usize, required_share_pct: f64, worst: Option<Decimal>) -> Self {
let share_pct = if samples == 0 {
100.0
} else {
within as f64 / samples as f64 * 100.0
};
Self {
samples,
within,
share_pct,
required_share_pct,
compliant: samples == 0 || share_pct + 1e-9 >= required_share_pct,
worst,
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct En50160Report {
pub intervals: usize,
pub covered_secs: i64,
pub covers_a_week: bool,
pub voltage_band: LimitOutcome,
pub voltage_absolute: LimitOutcome,
pub frequency: LimitOutcome,
pub thd_voltage: LimitOutcome,
}
impl En50160Report {
pub fn outcomes(&self) -> impl Iterator<Item = (&'static str, &LimitOutcome)> {
[
("voltage_band", &self.voltage_band),
("voltage_absolute", &self.voltage_absolute),
("frequency", &self.frequency),
("thd_voltage", &self.thd_voltage),
]
.into_iter()
.filter(|(_, o)| o.samples > 0)
}
#[must_use]
pub fn compliant(&self) -> bool {
self.outcomes().all(|(_, o)| o.compliant)
}
#[must_use]
pub fn is_conclusive(&self) -> bool {
self.covers_a_week && self.outcomes().next().is_some()
}
}
pub const OBSERVATION_WEEK_SECS: i64 = 7 * 24 * 3600;
#[must_use]
pub fn assess_en50160(intervals: &[PowerQualityInterval], limits: &En50160Limits) -> En50160Report {
let un = limits.nominal_voltage_v;
let band = un * limits.voltage_band_pct / Decimal::ONE_HUNDRED;
let upper = un + un * limits.voltage_absolute_upper_pct / Decimal::ONE_HUNDRED;
let lower = un - un * limits.voltage_absolute_lower_pct / Decimal::ONE_HUNDRED;
let nominal_hz = Decimal::from(50u32);
let freq_band = nominal_hz * limits.frequency_band_pct / Decimal::ONE_HUNDRED;
let mut band_tally = Tally::new(|v: Decimal| (v - un).abs());
let mut absolute_tally = Tally::new(|v: Decimal| (v - un).abs());
let mut freq_tally = Tally::new(|f: Decimal| (f - nominal_hz).abs());
let mut thd_tally = Tally::new(|t: Decimal| t);
let mut covered_secs = 0i64;
for iv in intervals {
covered_secs += (iv.to - iv.from).whole_seconds().max(0);
for v in iv.phase_voltages() {
band_tally.push(v, (v - un).abs() <= band);
absolute_tally.push(v, v <= upper && v >= lower);
}
if let Some(f) = iv.frequency_hz {
freq_tally.push(f, (f - nominal_hz).abs() <= freq_band);
}
if let Some(thd) = iv.thd_voltage_pct {
thd_tally.push(thd, thd <= limits.thd_max_pct);
}
}
En50160Report {
intervals: intervals.len(),
covered_secs,
covers_a_week: covered_secs >= OBSERVATION_WEEK_SECS,
voltage_band: band_tally.finish(limits.voltage_share_pct),
voltage_absolute: absolute_tally.finish(100.0),
frequency: freq_tally.finish(limits.frequency_share_pct),
thd_voltage: thd_tally.finish(limits.thd_share_pct),
}
}
struct Tally<F> {
samples: usize,
within: usize,
worst: Option<Decimal>,
worst_score: Decimal,
score: F,
}
impl<F: Fn(Decimal) -> Decimal> Tally<F> {
fn new(score: F) -> Self {
Self {
samples: 0,
within: 0,
worst: None,
worst_score: Decimal::MIN,
score,
}
}
fn push(&mut self, value: Decimal, inside: bool) {
self.samples += 1;
if inside {
self.within += 1;
return;
}
let score = (self.score)(value);
if score > self.worst_score {
self.worst_score = score;
self.worst = Some(value);
}
}
fn finish(self, required_share_pct: f64) -> LimitOutcome {
LimitOutcome::new(self.within, self.samples, required_share_pct, self.worst)
}
}
#[must_use]
pub fn exceedance_pct(outcome: &LimitOutcome) -> f64 {
(100.0 - outcome.share_pct).max(0.0)
}
#[must_use]
pub fn voltage_percentile(intervals: &[PowerQualityInterval], share: f64) -> Option<Decimal> {
let mut values: Vec<Decimal> = intervals
.iter()
.flat_map(|iv| iv.phase_voltages())
.collect();
if values.is_empty() {
return None;
}
values.sort_unstable();
let share = share.clamp(0.0, 1.0);
let rank = (share * values.len() as f64).ceil() as usize;
let index = rank.saturating_sub(1).min(values.len() - 1);
values.get(index).copied()
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::dec;
use time::{Duration, macros::datetime};
fn at(i: i64) -> (OffsetDateTime, OffsetDateTime) {
let from = datetime!(2026-06-01 0:00 UTC) + Duration::minutes(i * 10);
(from, from + Duration::minutes(10))
}
fn week(volts: Decimal) -> Vec<PowerQualityInterval> {
(0..1008)
.map(|i| {
let (from, to) = at(i);
PowerQualityInterval {
voltage_l1_v: Some(volts),
..PowerQualityInterval::empty(from, to)
}
})
.collect()
}
#[test]
fn a_nominal_week_is_compliant_and_conclusive() {
let report = assess_en50160(&week(dec!(231)), &En50160Limits::LOW_VOLTAGE);
assert!(report.compliant());
assert!(report.is_conclusive());
assert_eq!(report.voltage_band.samples, 1008);
assert_eq!(report.voltage_band.within, 1008);
assert!((report.voltage_band.share_pct - 100.0).abs() < 1e-9);
assert_eq!(report.voltage_band.worst, None);
}
#[test]
fn five_percent_outside_the_band_still_conforms() {
let mut series = week(dec!(231));
for iv in series.iter_mut().take(50) {
iv.voltage_l1_v = Some(dec!(254)); }
let report = assess_en50160(&series, &En50160Limits::LOW_VOLTAGE);
assert!(
report.voltage_band.compliant,
"{:.3} % inside, {} required",
report.voltage_band.share_pct, report.voltage_band.required_share_pct
);
series[50].voltage_l1_v = Some(dec!(254));
let report = assess_en50160(&series, &En50160Limits::LOW_VOLTAGE);
assert!(!report.voltage_band.compliant);
assert_eq!(report.voltage_band.worst, Some(dec!(254)));
}
#[test]
fn the_ninety_five_percent_boundary_is_exact() {
let build = |outside: usize| {
let mut series = week(dec!(231));
for iv in series.iter_mut().take(outside) {
iv.voltage_l1_v = Some(dec!(260));
}
assess_en50160(&series, &En50160Limits::LOW_VOLTAGE)
};
assert!(build(50).voltage_band.compliant);
assert!(!build(51).voltage_band.compliant);
}
#[test]
fn the_absolute_limit_admits_no_exceptions() {
let mut series = week(dec!(231));
series[500].voltage_l1_v = Some(dec!(260));
let report = assess_en50160(&series, &En50160Limits::LOW_VOLTAGE);
assert!(report.voltage_band.compliant, "one in 1 008 is within 5 %");
assert!(!report.voltage_absolute.compliant);
assert!(!report.compliant());
assert_eq!(report.voltage_absolute.worst, Some(dec!(260)));
}
#[test]
fn the_absolute_band_is_asymmetric() {
let low = {
let mut s = week(dec!(231));
s[0].voltage_l1_v = Some(dec!(200)); assess_en50160(&s, &En50160Limits::LOW_VOLTAGE)
};
assert!(low.voltage_absolute.compliant, "−13 % is inside −15 %");
let high = {
let mut s = week(dec!(231));
s[0].voltage_l1_v = Some(dec!(254)); assess_en50160(&s, &En50160Limits::LOW_VOLTAGE)
};
assert!(!high.voltage_absolute.compliant);
}
#[test]
fn a_short_series_is_not_conclusive() {
let day: Vec<_> = week(dec!(231)).into_iter().take(144).collect();
let report = assess_en50160(&day, &En50160Limits::LOW_VOLTAGE);
assert!(report.compliant(), "nothing breached");
assert!(!report.is_conclusive(), "but a day is not a week");
assert!(!report.covers_a_week);
assert_eq!(report.covered_secs, 144 * 600);
assert!(assess_en50160(&week(dec!(231)), &En50160Limits::LOW_VOLTAGE).covers_a_week);
}
#[test]
fn every_phase_counts_separately() {
let series: Vec<_> = (0..1008)
.map(|i| {
let (from, to) = at(i);
PowerQualityInterval {
voltage_l1_v: Some(dec!(231)),
voltage_l2_v: Some(dec!(229)),
voltage_l3_v: Some(dec!(232)),
..PowerQualityInterval::empty(from, to)
}
})
.collect();
let report = assess_en50160(&series, &En50160Limits::LOW_VOLTAGE);
assert_eq!(report.voltage_band.samples, 3024);
assert!(report.compliant());
}
#[test]
fn unmeasured_parameters_are_excluded_not_passed() {
let report = assess_en50160(&week(dec!(231)), &En50160Limits::LOW_VOLTAGE);
assert_eq!(report.frequency.samples, 0);
assert_eq!(report.thd_voltage.samples, 0);
let named: Vec<&str> = report.outcomes().map(|(n, _)| n).collect();
assert_eq!(named, vec!["voltage_band", "voltage_absolute"]);
assert!(report.compliant());
}
#[test]
fn frequency_and_thd_are_assessed_when_present() {
let mut series: Vec<_> = (0..1008)
.map(|i| {
let (from, to) = at(i);
PowerQualityInterval {
frequency_hz: Some(dec!(50.0)),
thd_voltage_pct: Some(dec!(3.0)),
..PowerQualityInterval::empty(from, to)
}
})
.collect();
assert!(assess_en50160(&series, &En50160Limits::LOW_VOLTAGE).compliant());
for iv in series.iter_mut().take(6) {
iv.frequency_hz = Some(dec!(48.0));
}
let report = assess_en50160(&series, &En50160Limits::LOW_VOLTAGE);
assert!(!report.frequency.compliant, "{:?}", report.frequency);
assert_eq!(report.frequency.worst, Some(dec!(48.0)));
assert!(report.thd_voltage.compliant);
for iv in series.iter_mut().take(6) {
iv.thd_voltage_pct = Some(dec!(12.0));
}
let report = assess_en50160(&series, &En50160Limits::LOW_VOLTAGE);
assert!(report.thd_voltage.compliant, "6 of 1 008 is inside 5 %");
}
#[test]
fn an_empty_series_is_vacuous_not_compliant() {
let report = assess_en50160(&[], &En50160Limits::LOW_VOLTAGE);
assert_eq!(report.intervals, 0);
assert!(!report.is_conclusive());
assert_eq!(report.outcomes().count(), 0);
}
#[test]
fn the_voltage_percentile_is_the_u95_a_report_prints() {
let mut series = week(dec!(230));
for iv in series.iter_mut().take(50) {
iv.voltage_l1_v = Some(dec!(245));
}
assert_eq!(voltage_percentile(&series, 0.95), Some(dec!(230)));
assert_eq!(voltage_percentile(&series, 1.0), Some(dec!(245)));
assert_eq!(voltage_percentile(&series, 0.0), Some(dec!(230)));
assert_eq!(voltage_percentile(&[], 0.95), None);
}
#[test]
fn per_interval_indicators_flag_single_samples() {
let (from, to) = at(0);
let iv = PowerQualityInterval {
voltage_l1_v: Some(dec!(254)),
frequency_hz: Some(dec!(49.0)),
power_factor: Some(dec!(0.85)),
thd_voltage_pct: Some(dec!(9.0)),
..PowerQualityInterval::empty(from, to)
};
assert!(iv.voltage_out_of_range(dec!(230), dec!(10)));
assert!(iv.frequency_out_of_range(dec!(0.5)));
assert!(iv.power_factor_below(dec!(0.9)));
assert!(iv.voltage_thd_exceeded(dec!(8)));
let ok = PowerQualityInterval {
voltage_l1_v: Some(dec!(231)),
frequency_hz: Some(dec!(50.0)),
power_factor: Some(dec!(0.98)),
thd_voltage_pct: Some(dec!(2.0)),
..PowerQualityInterval::empty(from, to)
};
assert!(!ok.voltage_out_of_range(dec!(230), dec!(10)));
assert!(!ok.frequency_out_of_range(dec!(0.5)));
assert!(!ok.power_factor_below(dec!(0.9)));
assert!(!ok.voltage_thd_exceeded(dec!(8)));
}
#[test]
fn absent_measurements_trip_nothing() {
let (from, to) = at(0);
let iv = PowerQualityInterval::empty(from, to);
assert!(!iv.voltage_out_of_range(dec!(230), dec!(10)));
assert!(!iv.frequency_out_of_range(dec!(0.5)));
assert!(!iv.power_factor_below(dec!(0.9)));
assert!(!iv.voltage_thd_exceeded(dec!(8)));
assert_eq!(iv.phase_voltages().count(), 0);
}
#[test]
fn a_zero_nominal_voltage_does_not_divide_by_zero() {
let (from, to) = at(0);
let iv = PowerQualityInterval {
voltage_l1_v: Some(dec!(231)),
..PowerQualityInterval::empty(from, to)
};
assert!(!iv.voltage_out_of_range(Decimal::ZERO, dec!(10)));
}
#[test]
fn exceedance_is_the_complement_of_the_share() {
let mut series = week(dec!(231));
for iv in series.iter_mut().take(101) {
iv.voltage_l1_v = Some(dec!(260));
}
let report = assess_en50160(&series, &En50160Limits::LOW_VOLTAGE);
let out = exceedance_pct(&report.voltage_band);
assert!((out - 10.02).abs() < 0.01, "got {out}");
}
#[test]
fn limits_are_configurable() {
let strict = En50160Limits {
voltage_band_pct: dec!(5),
..En50160Limits::LOW_VOLTAGE
};
let mut series = week(dec!(231));
for iv in series.iter_mut().take(200) {
iv.voltage_l1_v = Some(dec!(245)); }
assert!(
assess_en50160(&series, &En50160Limits::LOW_VOLTAGE)
.voltage_band
.compliant
);
assert!(!assess_en50160(&series, &strict).voltage_band.compliant);
}
#[test]
fn shares_are_floats_and_quantities_are_not() {
let mut series = week(dec!(231));
series[0].voltage_l1_v = Some(dec!(260.125));
let report = assess_en50160(&series, &En50160Limits::LOW_VOLTAGE);
let _: f64 = report.voltage_band.share_pct;
assert_eq!(
report.voltage_band.worst,
Some(dec!(260.125)),
"the reported worst value is the exact measurement"
);
}
}