use super::vor::SignalQualityMetrics;
pub fn compute_signal_quality(
clipping_ratio: Option<f64>,
audio: &[f64],
var_30: &[f64],
ref_30: &[f64],
sample_rate: f64,
window_iq_count: usize,
radial_history: &[f64],
) -> SignalQualityMetrics {
let snr_9960_db = tone_snr_db(
audio,
sample_rate,
9960.0,
&[9600.0, 9700.0, 9800.0, 10100.0, 10200.0, 10300.0],
);
let p30_var = rms_power(var_30);
let p30_ref = rms_power(ref_30);
let p30_min = p30_var.min(p30_ref);
let n30 = avg_tone_power(var_30, sample_rate, &[15.0, 20.0, 25.0, 35.0, 40.0, 45.0])
.min(avg_tone_power(
ref_30,
sample_rate,
&[15.0, 20.0, 25.0, 35.0, 40.0, 45.0],
))
.max(1e-12);
let snr_30_db = 10.0 * ((p30_min + 1e-12) / n30).log10();
let lock_score = phase_lock_score(var_30, ref_30);
let radial_var = circular_variance(radial_history);
let clipping_ratio_raw = clipping_ratio.unwrap_or(0.0).clamp(0.0, 1.0);
let clipping_ratio_display = if clipping_ratio_raw == 0.0 {
let resolution = 1.0 / (window_iq_count.max(1) as f64);
format!("<{:.2e}", resolution)
} else {
format_scientific(clipping_ratio_raw, 2)
};
SignalQualityMetrics {
clipping_ratio: clipping_ratio_display,
snr_30hz_db: round_decimals(snr_30_db, 2),
snr_9960hz_db: round_decimals(snr_9960_db, 2),
lock_quality: format_scientific(lock_score, 2),
radial_variance: format_scientific(radial_var, 2),
}
}
pub fn round_decimals(value: f64, decimals: u32) -> f64 {
let factor = 10_f64.powi(decimals as i32);
(value * factor).round() / factor
}
fn format_scientific(value: f64, decimals: usize) -> String {
format!("{value:.decimals$e}")
}
fn rms_power(signal: &[f64]) -> f64 {
if signal.is_empty() {
return 0.0;
}
signal.iter().map(|x| x * x).sum::<f64>() / signal.len() as f64
}
fn tone_power(signal: &[f64], sample_rate: f64, freq: f64) -> f64 {
if signal.len() < 4 || sample_rate <= 0.0 {
return 0.0;
}
let n = signal.len() as f64;
let mut i_sum = 0.0;
let mut q_sum = 0.0;
for (k, &x) in signal.iter().enumerate() {
let phase = 2.0 * std::f64::consts::PI * freq * k as f64 / sample_rate;
i_sum += x * phase.cos();
q_sum += x * phase.sin();
}
(i_sum * i_sum + q_sum * q_sum) / (n * n)
}
fn avg_tone_power(signal: &[f64], sample_rate: f64, freqs: &[f64]) -> f64 {
if freqs.is_empty() {
return 0.0;
}
let sum: f64 = freqs
.iter()
.map(|&f| tone_power(signal, sample_rate, f))
.sum();
sum / freqs.len() as f64
}
fn tone_snr_db(signal: &[f64], sample_rate: f64, tone_freq: f64, noise_freqs: &[f64]) -> f64 {
let p_sig = tone_power(signal, sample_rate, tone_freq).max(1e-12);
let p_noise = avg_tone_power(signal, sample_rate, noise_freqs).max(1e-12);
10.0 * (p_sig / p_noise).log10()
}
fn phase_lock_score(var_30: &[f64], ref_30: &[f64]) -> f64 {
let n = var_30.len().min(ref_30.len());
if n < 8 {
return 0.0;
}
let (mut dot, mut v2, mut r2) = (0.0, 0.0, 0.0);
for i in 0..n {
let v = var_30[i];
let r = ref_30[i];
dot += v * r;
v2 += v * v;
r2 += r * r;
}
let denom = (v2 * r2).sqrt();
if denom <= 1e-12 {
0.0
} else {
(dot.abs() / denom).clamp(0.0, 1.0)
}
}
fn circular_variance(radials_deg: &[f64]) -> f64 {
if radials_deg.len() < 2 {
return 1.0;
}
let mut c = 0.0;
let mut s = 0.0;
for ° in radials_deg {
let r = deg.to_radians();
c += r.cos();
s += r.sin();
}
let n = radials_deg.len() as f64;
let r = (c * c + s * s).sqrt() / n;
(1.0 - r).clamp(0.0, 1.0)
}
pub fn estimate_repeat_interval_seconds(hits: &[f64]) -> Option<f64> {
if hits.len() < 2 {
return None;
}
let mut deltas: Vec<f64> = hits
.windows(2)
.map(|w| w[1] - w[0])
.filter(|d| *d >= 7.0)
.collect();
if deltas.is_empty() {
return None;
}
let mut folded_candidates = Vec::new();
for d in &deltas {
for n in 1..=5 {
let candidate = *d / n as f64;
if (6.0..=14.0).contains(&candidate) {
folded_candidates.push(candidate);
}
}
}
if !folded_candidates.is_empty() {
folded_candidates.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
return Some(folded_candidates[folded_candidates.len() / 2]);
}
deltas.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
Some(deltas[deltas.len() / 2])
}