use thiserror::Error;
#[derive(Debug, Error)]
pub enum HifError {
#[error("insufficient samples: need {need}, got {got}")]
InsufficientSamples { need: usize, got: usize },
#[error("invalid HIF configuration: {0}")]
InvalidConfig(String),
#[error("computation error: {0}")]
ComputationError(String),
}
#[derive(Debug, Clone)]
pub struct HifConfig {
pub nominal_frequency_hz: f64,
pub sampling_rate_hz: f64,
pub detection_window_cycles: usize,
pub false_alarm_rate: f64,
}
impl HifConfig {
fn samples_per_cycle(&self) -> usize {
(self.sampling_rate_hz / self.nominal_frequency_hz).round() as usize
}
pub fn min_samples(&self) -> usize {
self.samples_per_cycle() * self.detection_window_cycles
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
A,
B,
C,
N,
}
#[derive(Debug, Clone)]
pub struct CurrentSample {
pub time_s: f64,
pub current_a: f64,
pub phase: Phase,
}
#[derive(Debug, Clone)]
pub struct HifFeatures {
pub even_harmonic_ratio: f64,
pub half_cycle_asymmetry: f64,
pub delta_energy: f64,
pub zero_sequence_current_a: f64,
pub arc_randomness: f64,
pub neutral_ground_voltage: f64,
}
#[derive(Debug, Clone)]
pub struct HifDetectionResult {
pub hif_detected: bool,
pub confidence: f64,
pub features: HifFeatures,
pub detection_time_s: f64,
pub fault_phase: Option<Phase>,
pub estimated_fault_resistance_ohm: f64,
pub algorithm_used: String,
}
pub struct HifDetector {
config: HifConfig,
}
impl HifDetector {
pub fn new(config: HifConfig) -> Self {
Self { config }
}
pub fn extract_features(&self, samples: &[CurrentSample]) -> Result<HifFeatures, HifError> {
let min = self.config.min_samples();
if samples.len() < min {
return Err(HifError::InsufficientSamples {
need: min,
got: samples.len(),
});
}
let phase_samples = dominant_phase_samples(samples);
let n = phase_samples.len();
let dt = if phase_samples.len() >= 2 {
phase_samples[1].time_s - phase_samples[0].time_s
} else {
1.0 / self.config.sampling_rate_hz
};
let f0 = self.config.nominal_frequency_hz;
let fs = self.config.sampling_rate_hz;
let fund_mag = goertzel_magnitude(&phase_samples, f0, fs);
let h2_mag = goertzel_magnitude(&phase_samples, 2.0 * f0, fs);
let h4_mag = goertzel_magnitude(&phase_samples, 4.0 * f0, fs);
let even_harmonic_ratio = if fund_mag > 1e-12 {
(h2_mag + h4_mag) / fund_mag
} else {
0.0
};
let (pos_rms, neg_rms) = half_cycle_rms(&phase_samples, dt);
let half_cycle_asymmetry = (pos_rms - neg_rms).abs() / (pos_rms + neg_rms + 1e-9);
let spc = self.config.samples_per_cycle().max(1);
let delta_energy = per_cycle_energy_delta(&phase_samples, spc, dt);
let neutral: Vec<f64> = samples
.iter()
.filter(|s| s.phase == Phase::N)
.map(|s| s.current_a)
.collect();
let zero_sequence_current_a = if neutral.is_empty() {
0.0
} else {
rms(&neutral)
};
let arc_randomness = peak_entropy(&phase_samples, spc);
let neutral_ground_voltage = zero_sequence_current_a * 1.0;
let _ = n;
Ok(HifFeatures {
even_harmonic_ratio,
half_cycle_asymmetry,
delta_energy,
zero_sequence_current_a,
arc_randomness,
neutral_ground_voltage,
})
}
pub fn detect(&self, samples: &[CurrentSample]) -> Result<HifDetectionResult, HifError> {
let features = self.extract_features(samples)?;
let c1 = self.detect_even_harmonics(&features);
let c2 = self.detect_asymmetry(&features);
let c3 = self.detect_incremental_energy(samples);
let combined = self.combine_evidence(&[c1, c2, c3]);
let threshold = 0.5 - self.config.false_alarm_rate * 2.0;
let threshold = threshold.clamp(0.3, 0.7);
let single_algorithm_trip = c1 >= 0.6 || c2 >= 0.6 || c3 >= 0.6;
let detection_time_s = samples
.iter()
.map(|s| s.time_s)
.fold(f64::NEG_INFINITY, f64::max);
let fault_phase = dominant_phase(samples);
let v_nom = 7200.0_f64;
let i_fault = features.zero_sequence_current_a + 1e-9;
let estimated_fault_resistance_ohm = v_nom / i_fault;
Ok(HifDetectionResult {
hif_detected: combined >= threshold || single_algorithm_trip,
confidence: combined,
features,
detection_time_s,
fault_phase,
estimated_fault_resistance_ohm,
algorithm_used: "EvenHarmonic+HalfCycleAsymmetry+IncrementalEnergy+DempsterShafer"
.to_string(),
})
}
fn detect_even_harmonics(&self, features: &HifFeatures) -> f64 {
let r = features.even_harmonic_ratio;
if r < 0.05 {
0.0
} else {
(0.5 + 0.5 * ((r - 0.05) / 0.45).min(1.0)).min(1.0)
}
}
fn detect_asymmetry(&self, features: &HifFeatures) -> f64 {
let a = features.half_cycle_asymmetry;
if a < 0.02 {
0.0
} else {
(a / 0.3).min(1.0)
}
}
fn detect_incremental_energy(&self, samples: &[CurrentSample]) -> f64 {
let min = self.config.min_samples();
if samples.len() < min {
return 0.0;
}
let phase_samples = dominant_phase_samples(samples);
let spc = self.config.samples_per_cycle().max(1);
let dt = if phase_samples.len() >= 2 {
phase_samples[1].time_s - phase_samples[0].time_s
} else {
1.0 / self.config.sampling_rate_hz
};
let energies = cycle_energies(&phase_samples, spc, dt);
if energies.len() < 2 {
return 0.0;
}
let deltas: Vec<f64> = energies.windows(2).map(|w| (w[1] - w[0]).abs()).collect();
let mean_d = deltas.iter().copied().sum::<f64>() / deltas.len() as f64;
let var_d = deltas.iter().map(|&d| (d - mean_d).powi(2)).sum::<f64>() / deltas.len() as f64;
let std_d = var_d.sqrt();
let cv = if mean_d > 1e-12 { std_d / mean_d } else { 0.0 };
(cv / 1.5).min(1.0)
}
fn combine_evidence(&self, confidences: &[f64]) -> f64 {
if confidences.is_empty() {
return 0.0;
}
if confidences.iter().all(|&c| c < 1e-9) {
return 0.0;
}
let log_odds_sum: f64 = confidences
.iter()
.map(|&c| {
let c = c.clamp(1e-6, 1.0 - 1e-6);
(c / (1.0 - c)).ln()
})
.sum();
let combined = 1.0 / (1.0 + (-log_odds_sum).exp());
combined.clamp(0.0, 1.0)
}
}
fn goertzel_magnitude(samples: &[&CurrentSample], target_hz: f64, fs: f64) -> f64 {
let n = samples.len();
if n == 0 {
return 0.0;
}
let k = (target_hz / fs * n as f64).round() as usize;
let omega = 2.0 * std::f64::consts::PI * k as f64 / n as f64;
let coeff = 2.0 * omega.cos();
let mut s_prev2 = 0.0_f64;
let mut s_prev1 = 0.0_f64;
for s in samples {
let s_curr = s.current_a + coeff * s_prev1 - s_prev2;
s_prev2 = s_prev1;
s_prev1 = s_curr;
}
let re = s_prev1 - s_prev2 * omega.cos();
let im = s_prev2 * omega.sin();
(re * re + im * im).sqrt() / n as f64
}
fn half_cycle_rms(samples: &[&CurrentSample], _dt: f64) -> (f64, f64) {
let pos: Vec<f64> = samples
.iter()
.filter(|s| s.current_a >= 0.0)
.map(|s| s.current_a)
.collect();
let neg: Vec<f64> = samples
.iter()
.filter(|s| s.current_a < 0.0)
.map(|s| s.current_a)
.collect();
(rms(&pos), rms(&neg))
}
fn rms(vals: &[f64]) -> f64 {
if vals.is_empty() {
return 0.0;
}
let sum_sq: f64 = vals.iter().map(|v| v * v).sum();
(sum_sq / vals.len() as f64).sqrt()
}
fn per_cycle_energy_delta(samples: &[&CurrentSample], spc: usize, dt: f64) -> f64 {
let energies = cycle_energies(samples, spc, dt);
if energies.len() < 2 {
return 0.0;
}
let deltas: Vec<f64> = energies.windows(2).map(|w| (w[1] - w[0]).abs()).collect();
deltas.iter().sum::<f64>() / deltas.len() as f64
}
fn cycle_energies(samples: &[&CurrentSample], spc: usize, dt: f64) -> Vec<f64> {
samples
.chunks(spc)
.filter(|chunk| chunk.len() == spc)
.map(|chunk| chunk.iter().map(|s| s.current_a * s.current_a * dt).sum())
.collect()
}
fn peak_entropy(samples: &[&CurrentSample], spc: usize) -> f64 {
let peaks: Vec<f64> = samples
.chunks(spc)
.filter(|c| c.len() == spc)
.filter_map(|chunk| chunk.iter().map(|s| s.current_a.abs()).reduce(f64::max))
.collect();
if peaks.is_empty() {
return 0.0;
}
let min_p = peaks.iter().cloned().fold(f64::INFINITY, f64::min);
let max_p = peaks.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let range = max_p - min_p;
if range < 1e-12 {
return 0.0; }
let n_bins = 8usize;
let mut bins = vec![0usize; n_bins];
for &p in &peaks {
let idx = (((p - min_p) / range) * (n_bins - 1) as f64).round() as usize;
let idx = idx.min(n_bins - 1);
bins[idx] += 1;
}
let total = peaks.len() as f64;
bins.iter()
.filter(|&&b| b > 0)
.map(|&b| {
let pr = b as f64 / total;
-pr * pr.ln()
})
.sum()
}
fn dominant_phase_samples(samples: &[CurrentSample]) -> Vec<&CurrentSample> {
let best = dominant_phase(samples).unwrap_or(Phase::A);
samples.iter().filter(|s| s.phase == best).collect()
}
fn dominant_phase(samples: &[CurrentSample]) -> Option<Phase> {
let phases = [Phase::A, Phase::B, Phase::C];
phases
.iter()
.map(|&ph| {
let vals: Vec<f64> = samples
.iter()
.filter(|s| s.phase == ph)
.map(|s| s.current_a.abs())
.collect();
let mean = if vals.is_empty() {
0.0
} else {
vals.iter().sum::<f64>() / vals.len() as f64
};
(ph, mean)
})
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(ph, _)| ph)
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::PI;
fn make_config() -> HifConfig {
HifConfig {
nominal_frequency_hz: 60.0,
sampling_rate_hz: 1200.0,
detection_window_cycles: 20,
false_alarm_rate: 0.01,
}
}
fn pure_sine(amplitude_a: f64, n_samples: usize) -> Vec<CurrentSample> {
let fs = 1200.0_f64;
let f0 = 60.0_f64;
(0..n_samples)
.map(|i| CurrentSample {
time_s: i as f64 / fs,
current_a: amplitude_a * (2.0 * PI * f0 * i as f64 / fs).sin(),
phase: Phase::A,
})
.collect()
}
fn arc_waveform(n_samples: usize) -> Vec<CurrentSample> {
let fs = 1200.0_f64;
let f0 = 60.0_f64;
(0..n_samples)
.map(|i| {
let t = i as f64 / fs;
let fundamental = 50.0 * (2.0 * PI * f0 * t).sin();
let h2 = 8.0 * (2.0 * PI * 2.0 * f0 * t).sin(); let h4 = 3.0 * (2.0 * PI * 4.0 * f0 * t).sin(); let raw = fundamental + h2 + h4;
let current_a = if raw >= 0.0 { raw * 1.20 } else { raw };
CurrentSample {
time_s: t,
current_a,
phase: Phase::A,
}
})
.collect()
}
#[test]
fn test_normal_load_no_hif() {
let cfg = make_config();
let det = HifDetector::new(cfg.clone());
let samples = pure_sine(30.0, cfg.min_samples() + 10);
let result = det.detect(&samples).expect("detect should not fail");
assert!(
!result.hif_detected,
"pure sine should not be flagged as HIF, confidence={}",
result.confidence
);
}
#[test]
fn test_synthetic_hif_detected() {
let cfg = make_config();
let det = HifDetector::new(cfg.clone());
let samples = arc_waveform(cfg.min_samples() + 10);
let result = det.detect(&samples).expect("detect should not fail");
assert!(
result.hif_detected,
"arc waveform should be detected as HIF, confidence={}",
result.confidence
);
}
#[test]
fn test_even_harmonics_computed() {
let cfg = make_config();
let det = HifDetector::new(cfg.clone());
let fs = 1200.0_f64;
let f0 = 60.0_f64;
let n = cfg.min_samples() + 10;
let samples: Vec<CurrentSample> = (0..n)
.map(|i| {
let t = i as f64 / fs;
CurrentSample {
time_s: t,
current_a: 50.0 * (2.0 * PI * f0 * t).sin()
+ 5.0 * (2.0 * PI * 2.0 * f0 * t).sin(),
phase: Phase::A,
}
})
.collect();
let features = det.extract_features(&samples).expect("extract_features");
assert!(
features.even_harmonic_ratio > 0.05,
"expected even_harmonic_ratio > 0.05, got {}",
features.even_harmonic_ratio
);
}
#[test]
fn test_half_cycle_asymmetry_positive() {
let cfg = make_config();
let det = HifDetector::new(cfg.clone());
let samples = arc_waveform(cfg.min_samples() + 10);
let features = det.extract_features(&samples).expect("extract_features");
assert!(
features.half_cycle_asymmetry > 0.0,
"asymmetric arc waveform must have positive asymmetry, got {}",
features.half_cycle_asymmetry
);
}
#[test]
fn test_confidence_combination_dempster_shafer() {
let cfg = make_config();
let det = HifDetector::new(cfg);
let inputs = [0.55_f64, 0.60, 0.65]; let combined = det.combine_evidence(&inputs);
let max_single = inputs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
assert!(
combined > max_single,
"Multiple weak concordant signals ({combined:.3}) should exceed \
the strongest single signal ({max_single:.3})"
);
}
#[test]
fn test_insufficient_samples_error() {
let cfg = make_config();
let det = HifDetector::new(cfg);
let samples = pure_sine(100.0, 100);
let err = det
.detect(&samples)
.expect_err("should fail with insufficient samples");
match err {
HifError::InsufficientSamples { need, got } => {
assert_eq!(need, 400, "need should be 400");
assert_eq!(got, 100, "got should be 100");
}
other => panic!("expected InsufficientSamples, got {:?}", other),
}
}
#[test]
fn test_zero_current_no_hif() {
let cfg = make_config();
let det = HifDetector::new(cfg.clone());
let dt = 1.0 / cfg.sampling_rate_hz;
let samples: Vec<CurrentSample> = (0..cfg.min_samples())
.map(|i| CurrentSample {
time_s: i as f64 * dt,
current_a: 0.0,
phase: Phase::A,
})
.collect();
let result = det.detect(&samples).expect("detect should succeed");
assert!(!result.hif_detected, "zero current should not trigger HIF");
assert!(
result.confidence < 0.5,
"confidence should be low for zero current, got {}",
result.confidence
);
}
#[test]
fn test_zero_sequence_current_from_neutral() {
let cfg = make_config();
let det = HifDetector::new(cfg.clone());
let dt = 1.0 / cfg.sampling_rate_hz;
let n = cfg.min_samples();
let mut samples = pure_sine(100.0, n);
for i in 0..n {
samples.push(CurrentSample {
time_s: i as f64 * dt,
current_a: 10.0,
phase: Phase::N,
});
}
let features = det
.extract_features(&samples)
.expect("extract_features should succeed");
assert!(
features.zero_sequence_current_a > 1.0,
"neutral current should produce zero_sequence_current_a > 1.0, got {}",
features.zero_sequence_current_a
);
let expected_ngv = features.zero_sequence_current_a * 1.0;
assert!(
(features.neutral_ground_voltage - expected_ngv).abs() < 1e-9,
"neutral_ground_voltage should equal zero_sequence_current_a * 1.0"
);
}
#[test]
fn test_combine_evidence_all_zero() {
let cfg = make_config();
let det = HifDetector::new(cfg.clone());
let samples = pure_sine(100.0, cfg.min_samples());
let result = det.detect(&samples).expect("detect should succeed");
assert!(
result.confidence < 0.48,
"pure-sine combined confidence should be below threshold 0.48, got {}",
result.confidence
);
}
#[test]
fn test_4th_harmonic_triggers_hif() {
let cfg = make_config();
let det = HifDetector::new(cfg.clone());
let dt = 1.0 / cfg.sampling_rate_hz;
let n = cfg.min_samples() + 10;
let samples: Vec<CurrentSample> = (0..n)
.map(|i| {
let t = i as f64 * dt;
let current_a =
50.0 * (2.0 * PI * 60.0 * t).sin() + 15.0 * (2.0 * PI * 240.0 * t).sin(); CurrentSample {
time_s: t,
current_a,
phase: Phase::A,
}
})
.collect();
let features = det
.extract_features(&samples)
.expect("extract_features should succeed");
assert!(
features.even_harmonic_ratio > 0.25,
"30% 4th harmonic → even_harmonic_ratio should be >0.25, got {}",
features.even_harmonic_ratio
);
let result = det.detect(&samples).expect("detect should succeed");
assert!(
result.hif_detected,
"strong 4th harmonic should trigger HIF detection"
);
}
#[test]
fn test_detection_time_matches_last_sample() {
let cfg = make_config();
let det = HifDetector::new(cfg.clone());
let n = cfg.min_samples() + 5;
let samples = pure_sine(100.0, n);
let last_time = samples.last().expect("samples must be non-empty").time_s;
let result = det.detect(&samples).expect("detect should succeed");
assert!(
(result.detection_time_s - last_time).abs() < 1e-12,
"detection_time_s should equal last sample time {}, got {}",
last_time,
result.detection_time_s
);
}
#[test]
fn test_fault_phase_dominant_phase_b() {
let cfg = make_config();
let det = HifDetector::new(cfg.clone());
let n = cfg.min_samples() + 10;
let samples: Vec<CurrentSample> = arc_waveform(n)
.into_iter()
.map(|s| CurrentSample {
phase: Phase::B,
..s
})
.collect();
let result = det.detect(&samples).expect("detect should succeed");
match result.fault_phase {
Some(Phase::B) => {}
other => panic!("expected fault_phase Some(Phase::B), got {:?}", other),
}
}
#[test]
fn test_higher_far_lowers_threshold() {
let n = {
let cfg = make_config();
cfg.min_samples() + 10
};
let samples = arc_waveform(n);
let cfg_low = make_config(); let det_low = HifDetector::new(cfg_low);
let result_low = det_low
.detect(&samples)
.expect("low-FAR detect should succeed");
let cfg_high = HifConfig {
false_alarm_rate: 0.20,
..make_config()
};
let det_high = HifDetector::new(cfg_high);
let result_high = det_high
.detect(&samples)
.expect("high-FAR detect should succeed");
assert!(
result_low.hif_detected,
"low-FAR detector should detect arc waveform"
);
assert!(
result_high.hif_detected,
"high-FAR detector should detect arc waveform"
);
}
}