Skip to main content

secs_engine/
scorecard.rs

1//! Typed before/after metrics, ported from `scorecard.metrics`.
2
3use crate::{
4    C,
5    decay::{energy, first_max, fit_slope, median, percentile},
6};
7use secs_dsp::{
8    fft, interp,
9    response::gd_curve_with_peak,
10    smooth::{Fraction, smooth_complex, smooth_power},
11};
12use std::f64::consts::PI;
13
14/// The 17 scalars, named as upstream's keys. Percentages are on a 0–100 scale.
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub struct Metrics {
17    pub level: f64,
18    pub in1_20: f64,
19    pub in2_20: f64,
20    pub in1_200: f64,
21    pub in2_200: f64,
22    pub in1_2000: f64,
23    pub in2_2000: f64,
24    pub turns: f64,
25    pub gd_20: f64,
26    pub gd_100: f64,
27    pub gd_500: f64,
28    pub gd24_abs: f64,
29    pub gd24_abs_hz: f64,
30    pub gd24_max: f64,
31    pub gd24_span: f64,
32    pub gd_30: f64,
33    pub pre_db: f64,
34}
35
36fn geomspace(start: f64, stop: f64, n: usize) -> Vec<f64> {
37    let lo = start.log10();
38    let step = (stop.log10() - lo) / (n - 1) as f64;
39    let mut out: Vec<_> = (0..n)
40        .map(|i| 10.0_f64.powf(i as f64 * step + lo))
41        .collect();
42    out[0] = start;
43    out[n - 1] = stop;
44    out
45}
46
47fn unwrap(phase: &mut [f64]) {
48    let mut previous = phase[0];
49    let mut correction = 0.0;
50    for p in &mut phase[1..] {
51        let d = *p - previous;
52        previous = *p;
53        let mut dm = (d + PI).rem_euclid(2.0 * PI) - PI;
54        if dm == -PI && d > 0.0 {
55            dm = PI;
56        }
57        if d.abs() >= PI {
58            correction += dm - d;
59        }
60        *p += correction;
61    }
62}
63
64fn gradient(y: &[f64], x: &[f64]) -> Vec<f64> {
65    let n = x.len();
66    let mut out = vec![0.0; n];
67    out[0] = (y[1] - y[0]) / (x[1] - x[0]);
68    out[n - 1] = (y[n - 1] - y[n - 2]) / (x[n - 1] - x[n - 2]);
69    for i in 1..n - 1 {
70        let d1 = x[i] - x[i - 1];
71        let d2 = x[i + 1] - x[i];
72        let a = -d2 / (d1 * (d1 + d2));
73        let b = (d2 - d1) / (d1 * d2);
74        let c = d1 / (d2 * (d1 + d2));
75        out[i] = a * y[i - 1] + b * y[i] + c * y[i + 1];
76    }
77    out
78}
79
80fn band(x: &[f64], y: &[f64], lo: f64, hi: f64) -> Vec<f64> {
81    x.iter()
82        .zip(y)
83        .filter(|(x, _)| **x >= lo && **x <= hi)
84        .map(|(_, y)| *y)
85        .collect()
86}
87
88/// `scorecard.metrics`. `h`, `f` and `target_db` have the same length, at least
89/// two; `f` is the linear real-FFT frequency grid with a positive Nyquist.
90pub fn metrics(h: &[C], f: &[f64], target_db: &[f64], norm: (f64, f64)) -> Metrics {
91    assert!(h.len() >= 2 && h.len() == f.len() && f.len() == target_db.len());
92    let (level, dev) = amplitude_deviation(h, f, target_db, norm);
93    let mut percentages = Vec::new();
94    for (lo, hi) in [(20.0, 200.0), (200.0, 2000.0), (2000.0, 16000.0)] {
95        let values = band(f, &dev, lo, hi);
96        for threshold in [1.0, 2.0] {
97            percentages.push(
98                100.0
99                    * (values.iter().filter(|v| v.abs() <= threshold).count() as f64
100                        / values.len() as f64),
101            );
102        }
103    }
104    let ir = fft::irfft(h, 2 * (h.len() - 1));
105    let peak = first_max(&ir.iter().map(|v| v.abs()).collect::<Vec<_>>());
106    let fs = 2.0 * f[f.len() - 1];
107    let (g, gd, turns) = phase_metrics(h, f, peak, fs);
108    let span = |lo, hi| {
109        let v = band(&g, &gd, lo, hi);
110        percentile(&v, 90.0) - percentile(&v, 10.0)
111    };
112    let g24 = geomspace(20.0, 500.0, 1500);
113    let gd24 = gd_curve_with_peak(h, f, &g24, Fraction::Scalar(24.0), peak).unwrap();
114    let k24 = first_max(&gd24);
115    let gd24_abs = gd24[k24];
116    let center = median(&gd24);
117    let centered: Vec<_> = gd24.iter().map(|d| d - center).collect();
118    let a3 = (0.003 * fs) as isize;
119    let a50 = (0.05 * fs) as isize;
120    let wrapped = |a: isize, b: isize| {
121        energy(
122            &(a..b)
123                .map(|i| ir[i.rem_euclid(ir.len() as isize) as usize])
124                .collect::<Vec<_>>(),
125        )
126    };
127    let pre = wrapped(peak as isize - a50, peak as isize - a3);
128    let main = wrapped(peak as isize - a3, peak as isize + a50);
129    Metrics {
130        level,
131        in1_20: percentages[0],
132        in2_20: percentages[1],
133        in1_200: percentages[2],
134        in2_200: percentages[3],
135        in1_2000: percentages[4],
136        in2_2000: percentages[5],
137        turns,
138        gd_20: span(20.0, 100.0),
139        gd_100: span(100.0, 500.0),
140        gd_500: span(500.0, 5000.0),
141        gd24_abs,
142        gd24_abs_hz: g24[k24],
143        gd24_max: gd24_abs - center,
144        gd24_span: percentile(&centered, 95.0) - percentile(&centered, 5.0),
145        gd_30: span(30.0, 100.0),
146        pre_db: 10.0 * (pre / (main + 1e-30) + 1e-30).log10(),
147    }
148}
149
150// Shared arithmetic with the full scorecard; selected metrics do not compute
151// unrelated transforms, plots, GD24, or pre-ringing windows.
152fn amplitude_deviation(h: &[C], f: &[f64], target_db: &[f64], norm: (f64, f64)) -> (f64, Vec<f64>) {
153    let power: Vec<_> = h
154        .iter()
155        .map(|h| {
156            let a = h.re.hypot(h.im);
157            a * a
158        })
159        .collect();
160    let db: Vec<_> = smooth_power(f, &power, Fraction::Scalar(6.0))
161        .unwrap()
162        .iter()
163        .zip(target_db)
164        .map(|(p, t)| 10.0 * (p + 1e-30).log10() - t)
165        .collect();
166    let level = median(&band(f, &db, norm.0, norm.1));
167    let dev: Vec<f64> = db.iter().map(|d| d - level).collect();
168    (level, dev)
169}
170
171fn phase_metrics(h: &[C], f: &[f64], peak: usize, fs: f64) -> (Vec<f64>, Vec<f64>, f64) {
172    let t = peak as f64 / fs;
173    let shifted: Vec<_> = h
174        .iter()
175        .zip(f)
176        .map(|(h, f)| h * C::from_polar(1.0, 0.0 + (2.0 * PI * f) * t))
177        .collect();
178    let hs = smooth_complex(f, &shifted, Fraction::Scalar(6.0)).unwrap();
179    let re: Vec<_> = hs.iter().map(|h| h.re).collect();
180    let im: Vec<_> = hs.iter().map(|h| h.im).collect();
181    let g = geomspace(20.0, 20000.0, 2000);
182    let mut phase: Vec<_> = g
183        .iter()
184        .map(|g| {
185            (C::new(interp(*g, f, &re), 0.0) + C::new(0.0, 1.0) * C::new(interp(*g, f, &im), 0.0))
186                .arg()
187        })
188        .collect();
189    unwrap(&mut phase);
190    let slope = fit_slope(
191        &band(&g, &g, 1000.0, 10000.0),
192        &band(&g, &phase, 1000.0, 10000.0),
193    );
194    for (p, g) in phase.iter_mut().zip(&g) {
195        *p -= slope * g;
196    }
197    let turns = (phase.iter().copied().fold(f64::NEG_INFINITY, f64::max)
198        - phase.iter().copied().fold(f64::INFINITY, f64::min))
199        / (2.0 * PI);
200    let x: Vec<_> = g.iter().map(|g| 2.0 * PI * g).collect();
201    let gd: Vec<_> = gradient(&phase, &x).iter().map(|v| -v * 1000.0).collect();
202    (g, gd, turns)
203}
204
205pub(crate) fn flatness_20(h: &[C], f: &[f64], target_db: &[f64], norm: (f64, f64)) -> f64 {
206    assert!(h.len() >= 2 && h.len() == f.len() && f.len() == target_db.len());
207    let (_, dev) = amplitude_deviation(h, f, target_db, norm);
208    let values = band(f, &dev, 20.0, 200.0);
209    100.0 * (values.iter().filter(|v| v.abs() <= 1.0).count() as f64 / values.len() as f64)
210}
211
212pub(crate) fn gd_span_20(h: &[C], f: &[f64]) -> f64 {
213    assert!(h.len() >= 2 && h.len() == f.len());
214    let ir = fft::irfft(h, 2 * (h.len() - 1));
215    let peak = first_max(&ir.iter().map(|v| v.abs()).collect::<Vec<_>>());
216    let (g, gd, _) = phase_metrics(h, f, peak, 2.0 * f[f.len() - 1]);
217    let v = band(&g, &gd, 20.0, 100.0);
218    percentile(&v, 90.0) - percentile(&v, 10.0)
219}
220
221#[cfg(test)]
222mod resource_tests {
223    use super::*;
224
225    #[test]
226    fn xo_partial_metrics_match_full() {
227        let f = crate::grid::design_freqs();
228        let h = secs_dsp::response::peq_response(f, &[(67.0, 2.1, -5.0), (123.0, 0.9, 3.0)]);
229        let target: Vec<f64> = f
230            .iter()
231            .map(|f| -0.1 * (f.max(1.0) / 20.0).log2())
232            .collect();
233        let all = metrics(&h, f, &target, (100.0, 400.0));
234        assert_eq!(
235            flatness_20(&h, f, &target, (100.0, 400.0)).to_bits(),
236            all.in1_20.to_bits()
237        );
238        assert_eq!(gd_span_20(&h, f).to_bits(), all.gd_20.to_bits());
239    }
240}