pedalkernel_validate/metrics.rs
1//! Audio signal comparison metrics.
2//!
3//! This module provides functions for comparing audio signals and measuring
4//! various quality metrics commonly used in audio circuit validation.
5//!
6//! # Available Metrics
7//!
8//! | Metric | Function | Description |
9//! |--------|----------|-------------|
10//! | Normalized RMS Error | [`normalized_rms_error_db`] | Overall signal difference (lower is better) |
11//! | Peak Error | [`peak_error_db`] | Maximum sample-level difference |
12//! | THD | [`thd_db`] | Total Harmonic Distortion |
13//! | THD Error | [`thd_error_db`] | THD difference between two signals |
14//! | Even/Odd Ratio | [`even_odd_ratio_db`] | Even vs odd harmonic balance |
15//! | Spectral Error | [`spectral_error_db`] | Frequency domain difference |
16//! | DC Drift | [`dc_drift_mv`] | Low-frequency offset measurement |
17//!
18//! # Example
19//!
20//! ```rust
21//! use pedalkernel_validate::metrics;
22//!
23//! let wdf_output: Vec<f64> = vec![0.1, 0.2, 0.3, 0.2, 0.1];
24//! let reference: Vec<f64> = vec![0.1, 0.2, 0.3, 0.2, 0.1];
25//! let sample_rate = 48000.0;
26//!
27//! // Compare individual metrics
28//! let rms_err = metrics::normalized_rms_error_db(&wdf_output, &reference);
29//! let peak_err = metrics::peak_error_db(&wdf_output, &reference);
30//!
31//! // Or use the combined comparison function
32//! let result = metrics::compare(&wdf_output, &reference, sample_rate, Some(1000.0));
33//! println!("RMS error: {:.1} dB", result.normalized_rms_error_db);
34//! println!("Peak error: {:.1} dB", result.peak_error_db);
35//! ```
36//!
37//! # Interpreting Results
38//!
39//! - **RMS Error**: -60 dB means error is 0.1% of signal. -80 dB is excellent.
40//! - **Peak Error**: Maximum instantaneous difference. Usually higher than RMS.
41//! - **THD**: Pure sine should be < -80 dB. Audible distortion starts around -40 dB.
42//! - **Even/Odd Ratio**: Push-pull amps should have < -40 dB (even harmonics suppressed)
43
44use realfft::RealFftPlanner;
45use rustfft::num_complex::Complex;
46
47/// Compute normalized RMS error in dB.
48///
49/// `error_db = 20 * log10(rms(wdf - ref) / rms(ref))`
50///
51/// Lower is better. -60 dB means error is 0.1% of signal.
52pub fn normalized_rms_error_db(wdf: &[f64], reference: &[f64]) -> f64 {
53 let n = wdf.len().min(reference.len());
54 if n == 0 {
55 return f64::NEG_INFINITY;
56 }
57
58 let mut err_sum = 0.0;
59 let mut ref_sum = 0.0;
60
61 for i in 0..n {
62 let diff = wdf[i] - reference[i];
63 err_sum += diff * diff;
64 ref_sum += reference[i] * reference[i];
65 }
66
67 let err_rms = (err_sum / n as f64).sqrt();
68 let ref_rms = (ref_sum / n as f64).sqrt();
69
70 if ref_rms < 1e-30 {
71 return -200.0;
72 }
73
74 20.0 * (err_rms / ref_rms).log10()
75}
76
77/// Compute peak absolute error in dB relative to reference peak.
78///
79/// `error_db = 20 * log10(max|wdf - ref| / max|ref|)`
80pub fn peak_error_db(wdf: &[f64], reference: &[f64]) -> f64 {
81 let n = wdf.len().min(reference.len());
82 if n == 0 {
83 return f64::NEG_INFINITY;
84 }
85
86 let mut max_err = 0.0_f64;
87 let mut max_ref = 0.0_f64;
88
89 for i in 0..n {
90 max_err = max_err.max((wdf[i] - reference[i]).abs());
91 max_ref = max_ref.max(reference[i].abs());
92 }
93
94 if max_ref < 1e-30 {
95 return -200.0;
96 }
97
98 20.0 * (max_err / max_ref).log10()
99}
100
101// ===========================================================================
102// Steady-state, level-INDEPENDENT measurement
103//
104// `normalized_rms_error_db` is a DIFFERENCE metric: rms(wdf-ref)/rms(ref). When
105// the two levels differ by a factor k it computes |k-1|, NOT shape — and worse,
106// when the WDF is far BELOW the golden (k→0) it reads ≈0 dB (because
107// rms(wdf-ref) ≈ rms(ref)), falsely implying "shapes match" while the WDF is
108// effectively silent. These functions measure the magnitude of the test tone
109// directly so the true gain gap is visible and is immune to any DC pedestal.
110// ===========================================================================
111
112/// Mean (DC offset) of a signal. Use as the AC-coupling / settle guard: an
113/// AC-coupled output that has reached steady state has |mean| ≈ 0.
114pub fn dc_offset(signal: &[f64]) -> f64 {
115 if signal.is_empty() {
116 return 0.0;
117 }
118 signal.iter().sum::<f64>() / signal.len() as f64
119}
120
121/// Single-bin DFT amplitude (peak, in signal units) of the component at exactly
122/// `freq_hz`.
123///
124/// Goertzel-style projection onto cos/sin at `freq_hz`. The DC offset is
125/// removed first, so the result is the AC amplitude AT THE TEST FREQUENCY and is
126/// immune to any DC pedestal (a device sitting at its bias point) or slow drift
127/// baseline. This is the steady-state level measurement that
128/// [`normalized_rms_error_db`] cannot provide.
129pub fn single_bin_amplitude(signal: &[f64], freq_hz: f64, sample_rate: f64) -> f64 {
130 let n = signal.len();
131 if n == 0 || sample_rate <= 0.0 {
132 return 0.0;
133 }
134 let mean = dc_offset(signal);
135 let w = 2.0 * std::f64::consts::PI * freq_hz / sample_rate;
136 let (mut re, mut im) = (0.0_f64, 0.0_f64);
137 for (i, &x) in signal.iter().enumerate() {
138 let a = w * i as f64;
139 let v = x - mean;
140 re += v * a.cos();
141 im += v * a.sin();
142 }
143 2.0 * (re * re + im * im).sqrt() / n as f64
144}
145
146/// Steady-state, level-independent AC gain ratio in dB at `freq_hz`:
147/// `20*log10(amp_wdf / amp_ref)`. Positive = WDF louder than the reference.
148///
149/// Unlike [`normalized_rms_error_db`], this directly compares the magnitude of
150/// the test tone, so it reports the TRUE level gap even when one signal rides on
151/// a large DC bias or is far below the other. Returns `-inf` when the reference
152/// tone is silent.
153pub fn ac_gain_db(wdf: &[f64], reference: &[f64], freq_hz: f64, sample_rate: f64) -> f64 {
154 let aw = single_bin_amplitude(wdf, freq_hz, sample_rate);
155 let ar = single_bin_amplitude(reference, freq_hz, sample_rate);
156 if ar < 1e-30 {
157 return f64::NEG_INFINITY;
158 }
159 20.0 * (aw / ar).log10()
160}
161
162/// AC-amplitude drift across a signal: dB difference between the single-bin
163/// amplitude of the SECOND half vs the FIRST half of `signal`.
164///
165/// ≈0 dB means the tone has SETTLED (steady state). A large magnitude means the
166/// window is still inside a transient (e.g. a coupling cap still charging), so
167/// any gain/shape number taken on it is unreliable. This is the programmatic
168/// guard that a measurement window is past the settling transient.
169pub fn ac_amplitude_drift_db(signal: &[f64], freq_hz: f64, sample_rate: f64) -> f64 {
170 let h = signal.len() / 2;
171 if h == 0 {
172 return 0.0;
173 }
174 let a1 = single_bin_amplitude(&signal[..h], freq_hz, sample_rate);
175 let a2 = single_bin_amplitude(&signal[h..], freq_hz, sample_rate);
176 if a1 < 1e-30 {
177 return f64::INFINITY;
178 }
179 20.0 * (a2 / a1).log10()
180}
181
182/// Compute THD (Total Harmonic Distortion) in dB.
183///
184/// THD = 10 * log10(sum(harmonic_powers) / fundamental_power)
185///
186/// Uses Blackman window for spectral analysis.
187pub fn thd_db(signal: &[f64], fundamental_hz: f64, sample_rate: f64, n_harmonics: usize) -> f64 {
188 let spectrum = compute_spectrum(signal);
189 let bin_hz = sample_rate / signal.len() as f64;
190
191 // Find fundamental bin
192 let fund_bin = (fundamental_hz / bin_hz).round() as usize;
193 if fund_bin >= spectrum.len() {
194 return -200.0;
195 }
196
197 let fund_power = spectrum[fund_bin].powi(2);
198 if fund_power < 1e-30 {
199 return -200.0;
200 }
201
202 // Sum harmonic powers
203 let mut harm_power = 0.0;
204 for h in 2..=n_harmonics {
205 let harm_freq = fundamental_hz * h as f64;
206 if harm_freq > sample_rate / 2.0 {
207 break;
208 }
209 let harm_bin = (harm_freq / bin_hz).round() as usize;
210 if harm_bin < spectrum.len() {
211 harm_power += spectrum[harm_bin].powi(2);
212 }
213 }
214
215 10.0 * (harm_power / fund_power).log10()
216}
217
218/// Compute THD error between WDF and reference in dB.
219pub fn thd_error_db(wdf: &[f64], reference: &[f64], fundamental_hz: f64, sample_rate: f64) -> f64 {
220 let thd_wdf = thd_db(wdf, fundamental_hz, sample_rate, 10);
221 let thd_ref = thd_db(reference, fundamental_hz, sample_rate, 10);
222 (thd_wdf - thd_ref).abs()
223}
224
225/// Compute even/odd harmonic ratio in dB.
226///
227/// For push-pull amplifiers, even harmonics should be suppressed.
228/// A good push-pull stage has ratio < -40 dB.
229pub fn even_odd_ratio_db(
230 signal: &[f64],
231 fundamental_hz: f64,
232 sample_rate: f64,
233 n_harmonics: usize,
234) -> f64 {
235 let spectrum = compute_spectrum(signal);
236 let bin_hz = sample_rate / signal.len() as f64;
237
238 let mut even_power = 0.0;
239 let mut odd_power = 0.0;
240
241 for h in 2..=n_harmonics {
242 let harm_freq = fundamental_hz * h as f64;
243 if harm_freq > sample_rate / 2.0 {
244 break;
245 }
246 let harm_bin = (harm_freq / bin_hz).round() as usize;
247 if harm_bin < spectrum.len() {
248 let power = spectrum[harm_bin].powi(2);
249 if h % 2 == 0 {
250 even_power += power;
251 } else {
252 odd_power += power;
253 }
254 }
255 }
256
257 if odd_power < 1e-30 {
258 return 0.0;
259 }
260
261 10.0 * (even_power / odd_power).log10()
262}
263
264/// Compute maximum spectral magnitude error in dB.
265///
266/// Compares spectra up to `max_freq_hz`, only at bins where **both** the
267/// reference and WDF signals have significant energy. The significance gate
268/// has two components:
269///
270/// 1. **Relative gate** — bin must be within 80 dB of the reference peak.
271/// 2. **Absolute floor** — both ref *and* WDF bins must be above −100 dBFS.
272///
273/// The absolute floor prevents false errors caused by comparing WDF's
274/// pristine numerical noise floor (≈ −316 dB) against ngspice's broadband
275/// numerical noise grass (≈ −95 dB). Without the floor, a single weak
276/// reference harmonic at −99 dBFS that the WDF places at −109 dBFS (below
277/// the simulator floor) would contribute a ≈ 200 dB "error" even though the
278/// real harmonics agree within 1–2 dB. The floor at −100 dBFS sits 5 dB
279/// *below* a typical 8-th harmonic of a well-simulated diode clipper
280/// (≈ −80 dBFS) and 5 dB *above* ngspice's typical noise grass (≈ −95 dBFS),
281/// so genuine circuit harmonics are still scored while simulator noise bins
282/// are excluded.
283pub fn spectral_error_db(
284 wdf: &[f64],
285 reference: &[f64],
286 sample_rate: f64,
287 max_freq_hz: Option<f64>,
288) -> f64 {
289 // Absolute noise-floor threshold: bins below this in *either* signal are
290 // excluded from scoring. Chosen to be above ngspice broadband noise
291 // grass (≈ −95 dBFS) but well below real diode harmonics (≥ −80 dBFS).
292 const ABS_FLOOR_DB: f64 = -100.0;
293
294 let n = wdf.len().min(reference.len());
295 if n == 0 {
296 return 0.0;
297 }
298
299 let wdf_spec = compute_spectrum(&wdf[..n]);
300 let ref_spec = compute_spectrum(&reference[..n]);
301
302 let bin_hz = sample_rate / n as f64;
303 let max_freq = max_freq_hz.unwrap_or(sample_rate / 4.0);
304 let max_bin = ((max_freq / bin_hz) as usize).min(wdf_spec.len());
305
306 // Relative threshold: within 80 dB of the reference peak.
307 let ref_peak_db = ref_spec[..max_bin]
308 .iter()
309 .map(|&x| 20.0 * (x + 1e-30).log10())
310 .fold(f64::NEG_INFINITY, f64::max);
311
312 // Combined threshold: whichever is higher (less permissive).
313 let threshold_db = (ref_peak_db - 80.0).max(ABS_FLOOR_DB);
314
315 let mut max_error = 0.0_f64;
316
317 for i in 1..max_bin {
318 let ref_db = 20.0 * (ref_spec[i] + 1e-30).log10();
319 // Gate 1 (relative + absolute): reference bin must be significant.
320 if ref_db > threshold_db {
321 let wdf_db = 20.0 * (wdf_spec[i] + 1e-30).log10();
322 // Gate 2 (absolute): WDF bin must also be above the noise floor.
323 // A WDF bin below ABS_FLOOR_DB compared to a reference bin just
324 // above it is simulator-noise noise, not a circuit accuracy gap.
325 if wdf_db > ABS_FLOOR_DB {
326 let error = (wdf_db - ref_db).abs();
327 max_error = max_error.max(error);
328 }
329 }
330 }
331
332 max_error
333}
334
335/// Measure DC drift over time.
336///
337/// Returns the maximum DC offset observed (using a moving average).
338pub fn dc_drift_mv(signal: &[f64], sample_rate: f64, window_ms: f64) -> f64 {
339 let window_samples = (window_ms * sample_rate / 1000.0) as usize;
340 if window_samples == 0 || signal.len() < window_samples {
341 return 0.0;
342 }
343
344 let mut max_dc = 0.0_f64;
345 let mut running_sum: f64 = signal[..window_samples].iter().sum();
346
347 for i in window_samples..signal.len() {
348 let dc = running_sum / window_samples as f64;
349 max_dc = max_dc.max(dc.abs());
350 running_sum += signal[i] - signal[i - window_samples];
351 }
352
353 max_dc * 1000.0 // Convert to mV
354}
355
356/// Compute THD+N (Total Harmonic Distortion plus Noise) in dB.
357///
358/// THD+N = 10 * log10((total_power - fundamental_power) / fundamental_power)
359///
360/// Unlike [`thd_db`], which sums only discrete harmonic bins, THD+N includes
361/// broadband noise and intermodulation products across ALL bins except the
362/// fundamental. Uses Blackman window for spectral analysis.
363///
364/// The fundamental exclusion zone is ±3 bins to prevent Blackman window
365/// sidelobes from artificially inflating the noise estimate.
366pub fn thd_plus_n_db(signal: &[f64], fundamental_hz: f64, sample_rate: f64) -> f64 {
367 const FUND_EXCL_BINS: usize = 3;
368
369 let spectrum = compute_spectrum(signal);
370 let bin_hz = sample_rate / signal.len() as f64;
371
372 let fund_bin = (fundamental_hz / bin_hz).round() as usize;
373 if fund_bin >= spectrum.len() {
374 return -200.0;
375 }
376
377 // Sum power in the exclusion zone around the fundamental to get
378 // the reference fundamental power.
379 let excl_lo = fund_bin.saturating_sub(FUND_EXCL_BINS);
380 let excl_hi = (fund_bin + FUND_EXCL_BINS + 1).min(spectrum.len());
381 let fund_power: f64 = spectrum[excl_lo..excl_hi].iter().map(|&x| x.powi(2)).sum();
382
383 if fund_power < 1e-30 {
384 return -200.0;
385 }
386
387 // Total power over all bins.
388 let total_power: f64 = spectrum.iter().map(|&x| x.powi(2)).sum();
389 // Noise+harmonic power = total minus the fundamental exclusion zone.
390 let noise_power = total_power - fund_power;
391
392 if noise_power <= 0.0 {
393 return -200.0;
394 }
395
396 10.0 * (noise_power / fund_power).log10()
397}
398
399/// Compute THD+N error between WDF and reference in dB.
400///
401/// Returns the absolute difference in THD+N between the two signals.
402pub fn thd_plus_n_error_db(
403 wdf: &[f64],
404 reference: &[f64],
405 fundamental_hz: f64,
406 sample_rate: f64,
407) -> f64 {
408 let wdf_tpn = thd_plus_n_db(wdf, fundamental_hz, sample_rate);
409 let ref_tpn = thd_plus_n_db(reference, fundamental_hz, sample_rate);
410 (wdf_tpn - ref_tpn).abs()
411}
412
413/// Compute the maximum harmonic magnitude error between WDF and reference in dB.
414///
415/// For each harmonic h in 2..=n_harmonics, reads both spectra at the h-th
416/// harmonic bin and computes `|20*log10(wdf_h) - 20*log10(ref_h)|`. Returns
417/// the MAX error over harmonics where **both** bins exceed the −100 dBFS
418/// absolute floor (same gate as [`spectral_error_db`]).
419///
420/// This is a targeted complement to [`spectral_error_db`]: it scores only
421/// the discrete harmonic bins, making it meaningful at high-drive levels and
422/// immune to broadband noise grass between harmonics.
423pub fn harmonic_mag_error_db(
424 wdf: &[f64],
425 reference: &[f64],
426 fundamental_hz: f64,
427 sample_rate: f64,
428 n_harmonics: usize,
429) -> f64 {
430 const ABS_FLOOR_DB: f64 = -100.0;
431
432 let n = wdf.len().min(reference.len());
433 if n == 0 {
434 return 0.0;
435 }
436
437 let wdf_spec = compute_spectrum(&wdf[..n]);
438 let ref_spec = compute_spectrum(&reference[..n]);
439 let bin_hz = sample_rate / n as f64;
440
441 let mut max_error = 0.0_f64;
442
443 for h in 2..=n_harmonics {
444 let harm_freq = fundamental_hz * h as f64;
445 if harm_freq > sample_rate / 2.0 {
446 break;
447 }
448 let harm_bin = (harm_freq / bin_hz).round() as usize;
449 if harm_bin >= wdf_spec.len() || harm_bin >= ref_spec.len() {
450 continue;
451 }
452
453 let wdf_db = 20.0 * (wdf_spec[harm_bin] + 1e-30).log10();
454 let ref_db = 20.0 * (ref_spec[harm_bin] + 1e-30).log10();
455
456 // Only score bins where both signals are above the noise floor.
457 if wdf_db > ABS_FLOOR_DB && ref_db > ABS_FLOOR_DB {
458 let error = (wdf_db - ref_db).abs();
459 max_error = max_error.max(error);
460 }
461 }
462
463 max_error
464}
465
466/// Compute even/odd harmonic ratio error between WDF and reference in dB.
467///
468/// Returns the absolute difference in [`even_odd_ratio_db`] between the two
469/// signals. Near zero for two signals with the same harmonic character;
470/// large when one has asymmetric and the other symmetric distortion.
471pub fn even_odd_ratio_error_db(
472 wdf: &[f64],
473 reference: &[f64],
474 fundamental_hz: f64,
475 sample_rate: f64,
476 n_harmonics: usize,
477) -> f64 {
478 let wdf_ratio = even_odd_ratio_db(wdf, fundamental_hz, sample_rate, n_harmonics);
479 let ref_ratio = even_odd_ratio_db(reference, fundamental_hz, sample_rate, n_harmonics);
480 (wdf_ratio - ref_ratio).abs()
481}
482
483/// Compute magnitude spectrum using Blackman window.
484fn compute_spectrum(signal: &[f64]) -> Vec<f64> {
485 let n = signal.len();
486 if n == 0 {
487 return vec![];
488 }
489
490 // Apply Blackman window
491 let mut windowed: Vec<f64> = signal
492 .iter()
493 .enumerate()
494 .map(|(i, &x)| {
495 let w = 0.42 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / (n - 1) as f64).cos()
496 + 0.08 * (4.0 * std::f64::consts::PI * i as f64 / (n - 1) as f64).cos();
497 x * w
498 })
499 .collect();
500
501 // Perform real FFT
502 let mut planner = RealFftPlanner::<f64>::new();
503 let fft = planner.plan_fft_forward(n);
504
505 let mut spectrum = vec![Complex::new(0.0, 0.0); n / 2 + 1];
506 fft.process(&mut windowed, &mut spectrum).unwrap();
507
508 // Return magnitudes
509 spectrum.iter().map(|c| c.norm() / n as f64).collect()
510}
511
512/// Result of comparing WDF output to reference.
513#[derive(Debug, Clone)]
514pub struct ComparisonResult {
515 pub normalized_rms_error_db: f64,
516 pub peak_error_db: f64,
517 pub thd_error_db: Option<f64>,
518 /// THD+N error between WDF and reference in dB. None when fundamental_hz
519 /// is not provided.
520 pub thd_plus_n_error_db: Option<f64>,
521 /// Maximum per-harmonic magnitude error in dB. None when fundamental_hz
522 /// is not provided.
523 pub harmonic_mag_error_db: Option<f64>,
524 /// Even/odd ratio error between WDF and reference in dB. None when
525 /// fundamental_hz is not provided.
526 pub even_odd_ratio_error_db: Option<f64>,
527 /// Spectral error within the audio band (capped at the audio Nyquist by the
528 /// production runners). This is the value checked against pass criteria.
529 pub spectral_error_db: f64,
530 /// Spectral error across the full data spectrum (up to sample_rate / 2, i.e.
531 /// the oversampled Nyquist). Informational only — shows what the audio-band
532 /// cap removes (ultrasonic content the engine anti-aliases away).
533 pub spectral_error_full_db: f64,
534 pub even_odd_ratio_db: Option<f64>,
535 pub dc_drift_mv: Option<f64>,
536}
537
538impl ComparisonResult {
539 /// Check if all metrics pass the given criteria.
540 pub fn passes(&self, criteria: &crate::config::PassCriteria) -> bool {
541 if self.normalized_rms_error_db > criteria.normalized_rms_error_db.unwrap_or(f64::INFINITY)
542 {
543 return false;
544 }
545 if self.peak_error_db > criteria.peak_error_db.unwrap_or(f64::INFINITY) {
546 return false;
547 }
548 if let (Some(thd_err), Some(thresh)) = (self.thd_error_db, criteria.thd_error_db) {
549 if thd_err > thresh {
550 return false;
551 }
552 }
553 if let (Some(v), Some(t)) = (self.thd_plus_n_error_db, criteria.thd_plus_n_error_db) {
554 if v > t {
555 return false;
556 }
557 }
558 if let (Some(v), Some(t)) = (self.harmonic_mag_error_db, criteria.harmonic_mag_error_db) {
559 if v > t {
560 return false;
561 }
562 }
563 if let (Some(v), Some(t)) = (
564 self.even_odd_ratio_error_db,
565 criteria.even_odd_ratio_error_db,
566 ) {
567 if v > t {
568 return false;
569 }
570 }
571 if self.spectral_error_db > criteria.spectral_error_db.unwrap_or(f64::INFINITY) {
572 return false;
573 }
574 if let (Some(dc), Some(thresh)) = (self.dc_drift_mv, criteria.max_dc_drift_mv) {
575 if dc > thresh {
576 return false;
577 }
578 }
579 true
580 }
581}
582
583/// Compare WDF output to reference with all metrics.
584pub fn compare(
585 wdf: &[f64],
586 reference: &[f64],
587 sample_rate: f64,
588 fundamental_hz: Option<f64>,
589) -> ComparisonResult {
590 let thd_err = fundamental_hz.map(|f| thd_error_db(wdf, reference, f, sample_rate));
591 let tpn_err =
592 fundamental_hz.map(|f| thd_plus_n_error_db(wdf, reference, f, sample_rate));
593 let harm_mag_err =
594 fundamental_hz.map(|f| harmonic_mag_error_db(wdf, reference, f, sample_rate, 10));
595 let eo_ratio_err =
596 fundamental_hz.map(|f| even_odd_ratio_error_db(wdf, reference, f, sample_rate, 10));
597 let even_odd = fundamental_hz.map(|f| even_odd_ratio_db(wdf, f, sample_rate, 10));
598
599 ComparisonResult {
600 normalized_rms_error_db: normalized_rms_error_db(wdf, reference),
601 peak_error_db: peak_error_db(wdf, reference),
602 thd_error_db: thd_err,
603 thd_plus_n_error_db: tpn_err,
604 harmonic_mag_error_db: harm_mag_err,
605 even_odd_ratio_error_db: eo_ratio_err,
606 spectral_error_db: spectral_error_db(wdf, reference, sample_rate, None),
607 // Full-band (raw) spectral error up to the data Nyquist. The production
608 // runners overwrite `spectral_error_db` with the audio-band cap, but
609 // leave this raw value intact for display.
610 spectral_error_full_db: spectral_error_db(
611 wdf,
612 reference,
613 sample_rate,
614 Some(sample_rate / 2.0),
615 ),
616 even_odd_ratio_db: even_odd,
617 dc_drift_mv: Some(dc_drift_mv(wdf, sample_rate, 100.0)),
618 }
619}
620
621// ============================================================================
622// DC operating-point (bias) accuracy metric
623// ============================================================================
624//
625// This module adds the missing *primary* validation axis for active circuits:
626// does our compile-time/settled DC bias agree with ngspice's `.op`? It answers
627// two distinct questions that map to the literature's Layer A / Layer B
628// (`docs/dc-operating-point-solve.md`):
629//
630// 1. **MATCH** (Layer B): per-device ΔVbe / ΔVce / ΔIc between OUR settled DC
631// operating point and ngspice's `.op`. A large MATCH error with a small
632// RESIDUAL is a *solver / root-selection* problem — our equations are right
633// but the cold solve lands in the wrong basin.
634//
635// 2. **RESIDUAL** (Layer A): `F(ngspice_op)` — is ngspice's `.op` a zero of OUR
636// DC equations? A large residual is a *formulation* bug — our bias math
637// genuinely differs from spice, independent of which root we converge to.
638//
639// The metric is plain-numeric so it stays decoupled from `pedalkernel-rt` (which
640// is a dev-dependency only): the test harness reads the engine's per-device bias
641// and per-port residual and feeds them in here.
642pub mod op_point {
643 use crate::spice::SpiceDeviceOp;
644
645 /// Our (WDF) DC operating point for a single device, read at the engine's
646 /// settled bias. Sign convention is the engine's *port* convention; the
647 /// comparison normalizes against ngspice's junction-magnitude convention.
648 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
649 pub struct DeviceBias {
650 /// Component reference, uppercased (e.g. `Q1`).
651 pub reference: String,
652 /// Base–emitter port voltage, volts.
653 pub vbe: f64,
654 /// Collector–emitter port voltage, volts.
655 pub vce: f64,
656 /// Collector current, amps.
657 pub ic: f64,
658 }
659
660 /// Per-device MATCH comparison (ours vs ngspice).
661 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
662 pub struct DeviceMatch {
663 pub reference: String,
664 pub model: String,
665 /// Our reading (Vbe, Vce, Ic).
666 pub ours: (f64, f64, f64),
667 /// ngspice `.op` (Vbe, Vce, Ic).
668 pub spice: (f64, f64, f64),
669 /// ΔVbe = ours − spice (volts), compared on junction magnitude.
670 pub d_vbe: f64,
671 /// ΔVce = ours − spice (volts), compared on magnitude.
672 pub d_vce: f64,
673 /// ΔIc as a percentage of |spice Ic| (∞-guarded: 0 when spice Ic ≈ 0).
674 pub d_ic_pct: f64,
675 }
676
677 /// Per-port DC-balance residual `F(ngspice_op)` (from
678 /// `MultiNlStage::op_seed_residual`, fed in as plain numbers).
679 #[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
680 pub struct PortResidual {
681 /// Static formulation residual `a − dc_bias − cap − nl − adapt` (volts).
682 pub residual: f64,
683 /// Full residual (includes the runtime DC servo); ≈ 0 at the engine's own
684 /// fixed point — calibrates the probe.
685 pub residual_full: f64,
686 }
687
688 /// Thresholds for grading the bias-accuracy dimension. Informative, not
689 /// punitive — tuned to localize Layer A vs Layer B, not to gate CI.
690 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
691 pub struct OpPassCriteria {
692 /// ΔVbe warn threshold (volts). Default 10 mV.
693 pub vbe_warn_v: f64,
694 /// ΔVbe fail threshold (volts). Default 25 mV.
695 pub vbe_fail_v: f64,
696 /// ΔVce fail threshold (volts). Default 0.3 V.
697 pub vce_fail_v: f64,
698 /// ΔIc fail threshold (percent). Default 10 %.
699 pub ic_fail_pct: f64,
700 /// Static-residual threshold separating "formulation ≈ ok" from a
701 /// formulation bug (volts). Default 0.25 V.
702 ///
703 /// This is a DC-balance error in volts at a device port. Once the reactive
704 /// (capacitor) ports are seeded to ngspice's `.op` node voltages — the FULL
705 /// operating-point seed, not the old cold-cap probe — a residual on the
706 /// order of a junction drop means ngspice's `.op` is genuinely NOT a fixed
707 /// point of our DC equations (a formulation bug, Layer A). The BA283 lands
708 /// at ≈ 0.615 V (Q3 Vbe dominant) → Layer A. Tens-of-mV residuals still read
709 /// "≈ ok" → Layer B (solver / root-selection). The prior 1.0 V default was
710 /// set against a CONTAMINATED, cold-cap residual (≈ 0.04 V) and mis-called
711 /// the BA283 Layer B; with the caps seeded the true 0.615 V exceeds this.
712 pub residual_formulation_v: f64,
713 }
714
715 impl Default for OpPassCriteria {
716 fn default() -> Self {
717 Self {
718 vbe_warn_v: 0.010,
719 vbe_fail_v: 0.025,
720 vce_fail_v: 0.3,
721 ic_fail_pct: 10.0,
722 residual_formulation_v: 0.25,
723 }
724 }
725 }
726
727 /// The formulation-vs-solver verdict for one circuit.
728 #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
729 pub enum Verdict {
730 /// Residual ≈ 0 AND MATCH within thresholds — bias is right.
731 BiasOk,
732 /// Residual ≈ 0 but MATCH off — ngspice's `.op` IS a fixed point of our
733 /// equations, but the cold solve converged to a different root.
734 /// (Layer B: solver / root-selection.)
735 FormulationOkSolverOff,
736 /// Residual far from 0 — ngspice's `.op` is NOT a fixed point of our
737 /// equations. (Layer A: formulation bug.)
738 FormulationBug,
739 /// No active devices / no residual ports — nothing to grade.
740 NoData,
741 }
742
743 impl Verdict {
744 pub fn label(&self) -> &'static str {
745 match self {
746 Verdict::BiasOk => "BIAS OK",
747 Verdict::FormulationOkSolverOff => "FORMULATION ~OK / SOLVER-OFF (Layer B)",
748 Verdict::FormulationBug => "FORMULATION BUG (Layer A)",
749 Verdict::NoData => "NO DATA",
750 }
751 }
752 }
753
754 /// Full bias-accuracy result for one circuit.
755 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
756 pub struct OpPointResult {
757 pub circuit: String,
758 pub devices: Vec<DeviceMatch>,
759 /// Worst per-device readings (rolled up).
760 pub max_abs_d_vbe: f64,
761 pub max_abs_d_vce: f64,
762 pub max_abs_d_ic_pct: f64,
763 /// Worst static residual across ports (volts) — the formulation probe.
764 pub max_abs_residual: f64,
765 /// Worst full residual across ports (volts) — should be ≈ 0 (calibration).
766 pub max_abs_residual_full: f64,
767 pub n_residual_ports: usize,
768 pub verdict: Verdict,
769 }
770
771 impl OpPointResult {
772 /// True when MATCH is within all fail thresholds.
773 pub fn match_ok(&self, c: &OpPassCriteria) -> bool {
774 self.max_abs_d_vbe <= c.vbe_fail_v
775 && self.max_abs_d_vce <= c.vce_fail_v
776 && self.max_abs_d_ic_pct <= c.ic_fail_pct
777 }
778 /// True when the static residual is small enough to call the formulation
779 /// approximately correct.
780 pub fn formulation_ok(&self, c: &OpPassCriteria) -> bool {
781 self.max_abs_residual <= c.residual_formulation_v
782 }
783 }
784
785 /// Compute the per-device MATCH between our settled bias and ngspice `.op`.
786 ///
787 /// Devices are paired by uppercased reference. Comparison is on junction
788 /// magnitude (|Vbe|, |Vce|, |Ic|) so PNP/NPN and port-vs-node sign
789 /// conventions don't masquerade as errors.
790 pub fn match_devices(ours: &[DeviceBias], spice: &[SpiceDeviceOp]) -> Vec<DeviceMatch> {
791 let mut out = Vec::new();
792 for s in spice {
793 let key = s.device.to_uppercase();
794 let Some(o) = ours.iter().find(|d| d.reference.to_uppercase() == key) else {
795 continue;
796 };
797 let d_vbe = o.vbe.abs() - s.vbe.abs();
798 let d_vce = o.vce.abs() - s.vce.abs();
799 let d_ic_pct = if s.ic.abs() > 1e-12 {
800 100.0 * (o.ic.abs() - s.ic.abs()) / s.ic.abs()
801 } else {
802 0.0
803 };
804 out.push(DeviceMatch {
805 reference: o.reference.clone(),
806 model: s.model.clone(),
807 ours: (o.vbe, o.vce, o.ic),
808 spice: (s.vbe, s.vce, s.ic),
809 d_vbe,
810 d_vce,
811 d_ic_pct,
812 });
813 }
814 out
815 }
816
817 /// Assemble the full bias-accuracy result + verdict for one circuit.
818 pub fn evaluate(
819 circuit: &str,
820 ours: &[DeviceBias],
821 spice: &[SpiceDeviceOp],
822 residuals: &[PortResidual],
823 criteria: &OpPassCriteria,
824 ) -> OpPointResult {
825 let devices = match_devices(ours, spice);
826 let max_abs_d_vbe = devices.iter().map(|d| d.d_vbe.abs()).fold(0.0, f64::max);
827 let max_abs_d_vce = devices.iter().map(|d| d.d_vce.abs()).fold(0.0, f64::max);
828 let max_abs_d_ic_pct = devices.iter().map(|d| d.d_ic_pct.abs()).fold(0.0, f64::max);
829 let max_abs_residual = residuals.iter().map(|p| p.residual.abs()).fold(0.0, f64::max);
830 let max_abs_residual_full =
831 residuals.iter().map(|p| p.residual_full.abs()).fold(0.0, f64::max);
832
833 let mut result = OpPointResult {
834 circuit: circuit.to_string(),
835 devices,
836 max_abs_d_vbe,
837 max_abs_d_vce,
838 max_abs_d_ic_pct,
839 max_abs_residual,
840 max_abs_residual_full,
841 n_residual_ports: residuals.len(),
842 verdict: Verdict::NoData,
843 };
844
845 result.verdict = if result.devices.is_empty() && residuals.is_empty() {
846 Verdict::NoData
847 } else if !result.formulation_ok(criteria) {
848 Verdict::FormulationBug
849 } else if result.match_ok(criteria) {
850 Verdict::BiasOk
851 } else {
852 Verdict::FormulationOkSolverOff
853 };
854 result
855 }
856}
857
858// ============================================================================
859// AC-signal accuracy metric (the audio twin of the DC bias-accuracy dashboard)
860// ============================================================================
861//
862// The DC dashboard (`op_point`) grades the operating point; this module grades
863// the AC SIGNAL — WDF vs the ngspice-derived golden. Its whole reason to exist
864// is that `normalized_rms_error_db` DEGENERATES for a pure-gain-offset circuit:
865// when the WDF and golden differ only by a scalar level `k`, that difference
866// metric reads |k−1| (and → 0 dB as the WDF falls far below the golden), so it
867// can neither see the true level gap nor the shape. The BA283 is exactly such a
868// circuit (a clean Class-A gain block sitting at a fixed +2.42 dB level offset
869// with the DC servo on), so a single-bin gain scalar is all the old test could
870// honestly report.
871//
872// The fix here is to DECOMPOSE the AC error into orthogonal dimensions so each
873// is actually measured:
874//
875// 1. LEVEL — `ac_gain_db` at the test tone (the scalar offset, e.g. +2.42).
876// 2. SHAPE — gain-normalize the WDF (× golden_amp/wdf_amp) so the level is
877// factored OUT, THEN measure `normalized_rms_error_db` (phase-
878// sensitive, time domain) and `spectral_error_db` (phase-immune,
879// magnitude) on the level-matched pair = the honest shape residual.
880// 3. HARMONICS — `thd_db(wdf)` vs `thd_db(golden)` (a ratio, inherently level-
881// independent) plus per-harmonic (2nd–5th) magnitude error read in
882// dBc (relative to the fundamental), which is also level-immune.
883// 4. RESPONSE — `ac_gain_db` across a frequency sweep. If the per-frequency
884// gain is FLAT the error is a pure scalar (a level bug); if it is
885// frequency-SHAPED it is a genuine response error.
886//
887// The verdict then states plainly whether the AC error is JUST the level offset
888// (shape/THD/response all clean ⇒ a flat scalar bug) or ALSO a shape / harmonic /
889// response error (⇒ more than one bug). Plain-numeric like `op_point`, so it
890// stays decoupled from `pedalkernel-rt`.
891pub mod ac_accuracy {
892 use super::{
893 ac_gain_db, normalized_rms_error_db, single_bin_amplitude, spectral_error_db, thd_db,
894 };
895 use serde::{Deserialize, Serialize};
896
897 /// Harmonic orders scored per-tone (2nd through 5th).
898 pub const HARMONIC_ORDERS: [usize; 4] = [2, 3, 4, 5];
899
900 /// A golden harmonic weaker than this (relative to the fundamental) is treated
901 /// as absent (below the simulator noise grass): its per-harmonic error is not
902 /// scored for the verdict, so ngspice noise bins don't masquerade as shape
903 /// error. −80 dBc sits above ngspice's typical noise grass yet below a real
904 /// diode/BJT harmonic.
905 pub const HARMONIC_FLOOR_DBC: f64 = -80.0;
906
907 /// One harmonic's level, WDF vs golden, expressed in dBc (relative to the
908 /// fundamental) so it is level-independent by construction.
909 #[derive(Debug, Clone, Serialize, Deserialize)]
910 pub struct HarmonicError {
911 /// Harmonic order (2..=5).
912 pub order: usize,
913 /// WDF harmonic level relative to its own fundamental, dB.
914 pub wdf_dbc: f64,
915 /// Golden harmonic level relative to its own fundamental, dB.
916 pub golden_dbc: f64,
917 /// `|wdf_dbc − golden_dbc|`, the level-independent per-harmonic residual.
918 pub error_db: f64,
919 /// True when the GOLDEN harmonic is above [`HARMONIC_FLOOR_DBC`] (present,
920 /// not noise). Only scored harmonics feed the verdict.
921 pub scored: bool,
922 }
923
924 /// One frequency-sweep point: WDF-vs-golden AC gain at that frequency.
925 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
926 pub struct FreqPoint {
927 pub freq_hz: f64,
928 /// `ac_gain_db(wdf, golden)` at this frequency (WDF louder ⇒ positive).
929 pub gain_db: f64,
930 }
931
932 /// Thresholds for grading the AC dimensions. Informative (localizes which
933 /// dimension carries the error), not a hard CI gate.
934 #[derive(Debug, Clone, Serialize, Deserialize)]
935 pub struct AcThresholds {
936 /// A |gain| above this at the test tone counts as a LEVEL offset. 1 dB.
937 pub gain_warn_db: f64,
938 /// Gain-normalized RMS shape residual (dB) above which SHAPE is bad. The
939 /// residual is `20·log10(rms(wdf_norm − golden)/rms(golden))`, so −20 dB
940 /// (≈ 10 % shape difference) is the boundary. NOTE: this is phase-
941 /// SENSITIVE — a pure group-delay difference inflates it even when the
942 /// magnitude shape agrees; cross-check with `shape_spectral`.
943 pub shape_rms_fail_db: f64,
944 /// Gain-normalized, phase-IMMUNE in-band magnitude shape error (dB) above
945 /// which SHAPE is bad. 3 dB.
946 pub shape_spectral_fail_db: f64,
947 /// |THD_wdf − THD_golden| (dB) above which HARMONICS are bad. 6 dB.
948 pub thd_error_fail_db: f64,
949 /// Max per-harmonic dBc error above which HARMONICS are bad. 6 dB.
950 pub harmonic_fail_db: f64,
951 /// Response tilt (max−min gain across the sweep, dB) above which the error
952 /// is frequency-SHAPED (a RESPONSE error, not a flat scalar). 3 dB.
953 pub response_tilt_fail_db: f64,
954 }
955
956 impl Default for AcThresholds {
957 fn default() -> Self {
958 Self {
959 gain_warn_db: 1.0,
960 shape_rms_fail_db: -20.0,
961 shape_spectral_fail_db: 3.0,
962 thd_error_fail_db: 6.0,
963 harmonic_fail_db: 6.0,
964 response_tilt_fail_db: 3.0,
965 }
966 }
967 }
968
969 /// The AC-fidelity verdict for one circuit — which dimension carries the error.
970 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
971 pub enum AcVerdict {
972 /// Level, shape, harmonics AND response all within thresholds.
973 Clean,
974 /// ONLY the level tone is off; shape + THD + response are all clean ⇒ the
975 /// AC error is a FLAT SCALAR (a pure-gain bug), the waveform shape matches.
976 LevelOnly,
977 /// The gain-normalized shape residual exceeds threshold ⇒ the waveform
978 /// shape itself differs (beyond a scalar level).
979 ShapeError,
980 /// THD or a per-harmonic level differs ⇒ the nonlinearity/harmonic content
981 /// differs (beyond a scalar level).
982 HarmonicError,
983 /// The per-frequency gain is not flat ⇒ a frequency-shaped RESPONSE error.
984 ResponseError,
985 /// No usable golden tone (silence) — nothing to grade.
986 NoData,
987 }
988
989 impl AcVerdict {
990 pub fn label(&self) -> &'static str {
991 match self {
992 AcVerdict::Clean => "AC OK",
993 AcVerdict::LevelOnly => "LEVEL-ONLY (flat scalar; shape clean)",
994 AcVerdict::ShapeError => "SHAPE ERROR (waveform differs)",
995 AcVerdict::HarmonicError => "HARMONIC ERROR (THD/harmonics differ)",
996 AcVerdict::ResponseError => "RESPONSE ERROR (frequency-shaped)",
997 AcVerdict::NoData => "NO DATA",
998 }
999 }
1000 }
1001
1002 /// Full AC-accuracy result for one circuit.
1003 #[derive(Debug, Clone, Serialize, Deserialize)]
1004 pub struct AcResult {
1005 pub circuit: String,
1006 /// Fundamental at which the level/shape/harmonic decomposition was taken.
1007 pub test_freq_hz: f64,
1008 /// LEVEL: `ac_gain_db(wdf, golden)` at the test tone (the scalar offset).
1009 pub gain_db: f64,
1010 /// WDF single-bin amplitude at the test tone (signal units).
1011 pub wdf_amp: f64,
1012 /// Golden single-bin amplitude at the test tone (signal units).
1013 pub golden_amp: f64,
1014 /// SHAPE (level factored out, phase-sensitive time domain), dB.
1015 pub shape_rms_db: f64,
1016 /// SHAPE (level factored out, phase-immune magnitude), dB.
1017 pub shape_spectral_db: f64,
1018 /// THD of the WDF tone, dB.
1019 pub thd_wdf_db: f64,
1020 /// THD of the golden tone, dB.
1021 pub thd_golden_db: f64,
1022 /// Per-harmonic (2nd–5th) dBc levels + error.
1023 pub harmonics: Vec<HarmonicError>,
1024 /// Frequency-response sweep (per-frequency WDF-vs-golden gain).
1025 pub response: Vec<FreqPoint>,
1026 /// Response tilt = max−min gain across the sweep, dB. ≈0 ⇒ flat (a scalar
1027 /// level offset); large ⇒ frequency-shaped response error.
1028 pub response_tilt_db: f64,
1029 pub verdict: AcVerdict,
1030 }
1031
1032 impl AcResult {
1033 /// Worst per-harmonic error over SCORED harmonics (golden present).
1034 pub fn max_harmonic_error_db(&self) -> f64 {
1035 self.harmonics
1036 .iter()
1037 .filter(|h| h.scored)
1038 .map(|h| h.error_db)
1039 .fold(0.0, f64::max)
1040 }
1041
1042 /// |THD_wdf − THD_golden|.
1043 pub fn thd_error_db(&self) -> f64 {
1044 (self.thd_wdf_db - self.thd_golden_db).abs()
1045 }
1046 }
1047
1048 /// One harmonic's level relative to the fundamental, in dBc, via single-bin
1049 /// projection (DC-immune). Returns `NEG_INFINITY` if the fundamental is silent.
1050 fn harmonic_dbc(signal: &[f64], fund_hz: f64, harm_hz: f64, sample_rate: f64) -> f64 {
1051 let fund = single_bin_amplitude(signal, fund_hz, sample_rate);
1052 if fund < 1e-30 {
1053 return f64::NEG_INFINITY;
1054 }
1055 let harm = single_bin_amplitude(signal, harm_hz, sample_rate);
1056 20.0 * (harm / fund).max(1e-12).log10()
1057 }
1058
1059 /// Response tilt: max−min over the finite per-frequency gains.
1060 pub fn response_tilt_db(response: &[FreqPoint]) -> f64 {
1061 let finite: Vec<f64> = response
1062 .iter()
1063 .map(|p| p.gain_db)
1064 .filter(|g| g.is_finite())
1065 .collect();
1066 if finite.len() < 2 {
1067 return 0.0;
1068 }
1069 let max = finite.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1070 let min = finite.iter().cloned().fold(f64::INFINITY, f64::min);
1071 max - min
1072 }
1073
1074 /// Decompose the WDF-vs-golden AC error into level / shape / harmonics /
1075 /// response and reach a verdict.
1076 ///
1077 /// `wdf` and `golden` are settled, same-rate, same-length, same-phase
1078 /// windows of the SAME test tone at `test_freq_hz`. `response` is the
1079 /// per-frequency gain sweep (may be empty; then RESPONSE is not graded).
1080 pub fn evaluate(
1081 circuit: &str,
1082 wdf: &[f64],
1083 golden: &[f64],
1084 sample_rate: f64,
1085 test_freq_hz: f64,
1086 response: Vec<FreqPoint>,
1087 th: &AcThresholds,
1088 ) -> AcResult {
1089 let n = wdf.len().min(golden.len());
1090 let wdf = &wdf[..n];
1091 let golden = &golden[..n];
1092
1093 let wdf_amp = single_bin_amplitude(wdf, test_freq_hz, sample_rate);
1094 let golden_amp = single_bin_amplitude(golden, test_freq_hz, sample_rate);
1095 let gain_db = ac_gain_db(wdf, golden, test_freq_hz, sample_rate);
1096
1097 // SHAPE: gain-normalize the WDF so the scalar level is factored OUT, then
1098 // measure the residual. This is THE fix for the degeneracy — after
1099 // normalization a pure level offset contributes nothing, so what remains
1100 // is the honest shape difference.
1101 let k = if wdf_amp > 1e-30 {
1102 golden_amp / wdf_amp
1103 } else {
1104 0.0
1105 };
1106 let wdf_norm: Vec<f64> = wdf.iter().map(|&x| k * x).collect();
1107 let shape_rms_db = normalized_rms_error_db(&wdf_norm, golden);
1108 let shape_spectral_db = spectral_error_db(&wdf_norm, golden, sample_rate, None);
1109
1110 // HARMONICS: THD ratio (level-independent) + per-harmonic dBc.
1111 let thd_wdf_db = thd_db(wdf, test_freq_hz, sample_rate, 10);
1112 let thd_golden_db = thd_db(golden, test_freq_hz, sample_rate, 10);
1113 let harmonics: Vec<HarmonicError> = HARMONIC_ORDERS
1114 .iter()
1115 .filter_map(|&order| {
1116 let hf = order as f64 * test_freq_hz;
1117 if hf >= sample_rate / 2.0 {
1118 return None;
1119 }
1120 let wdf_dbc = harmonic_dbc(wdf, test_freq_hz, hf, sample_rate);
1121 let golden_dbc = harmonic_dbc(golden, test_freq_hz, hf, sample_rate);
1122 let scored = golden_dbc > HARMONIC_FLOOR_DBC;
1123 Some(HarmonicError {
1124 order,
1125 wdf_dbc,
1126 golden_dbc,
1127 error_db: (wdf_dbc - golden_dbc).abs(),
1128 scored,
1129 })
1130 })
1131 .collect();
1132
1133 let response_tilt_db = response_tilt_db(&response);
1134
1135 let mut result = AcResult {
1136 circuit: circuit.to_string(),
1137 test_freq_hz,
1138 gain_db,
1139 wdf_amp,
1140 golden_amp,
1141 shape_rms_db,
1142 shape_spectral_db,
1143 thd_wdf_db,
1144 thd_golden_db,
1145 harmonics,
1146 response,
1147 response_tilt_db,
1148 verdict: AcVerdict::NoData,
1149 };
1150
1151 // Classify. Priority: a genuine shape/harmonic/response error outranks a
1152 // pure level offset (which is the benign, expected BA283 signature).
1153 let level_off = gain_db.abs() > th.gain_warn_db;
1154 let shape_bad = shape_rms_db > th.shape_rms_fail_db
1155 || shape_spectral_db > th.shape_spectral_fail_db;
1156 let harm_bad = result.thd_error_db() > th.thd_error_fail_db
1157 || result.max_harmonic_error_db() > th.harmonic_fail_db;
1158 let resp_bad =
1159 !result.response.is_empty() && response_tilt_db > th.response_tilt_fail_db;
1160
1161 result.verdict = if golden_amp < 1e-12 {
1162 AcVerdict::NoData
1163 } else if shape_bad {
1164 AcVerdict::ShapeError
1165 } else if harm_bad {
1166 AcVerdict::HarmonicError
1167 } else if resp_bad {
1168 AcVerdict::ResponseError
1169 } else if level_off {
1170 AcVerdict::LevelOnly
1171 } else {
1172 AcVerdict::Clean
1173 };
1174 result
1175 }
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180 use super::*;
1181
1182 #[test]
1183 fn identical_signals_have_zero_error() {
1184 let sig: Vec<f64> = (0..1000).map(|i| (i as f64 * 0.1).sin()).collect();
1185 assert!(normalized_rms_error_db(&sig, &sig) < -100.0);
1186 assert!(peak_error_db(&sig, &sig) < -100.0);
1187 }
1188
1189 #[test]
1190 fn single_bin_amplitude_recovers_tone_level() {
1191 let sr = 48000.0;
1192 let amp = 0.7;
1193 let sig: Vec<f64> = (0..48000)
1194 .map(|i| amp * (2.0 * std::f64::consts::PI * 1000.0 * i as f64 / sr).sin())
1195 .collect();
1196 let a = single_bin_amplitude(&sig, 1000.0, sr);
1197 assert!((a - amp).abs() < 1e-3, "expected ~{amp}, got {a}");
1198 }
1199
1200 #[test]
1201 fn single_bin_amplitude_is_immune_to_dc_pedestal() {
1202 // A device output sitting on a +5 V DC bias with a small AC tone.
1203 let sr = 48000.0;
1204 let ac = 0.05;
1205 let sig: Vec<f64> = (0..48000)
1206 .map(|i| 5.0 + ac * (2.0 * std::f64::consts::PI * 1000.0 * i as f64 / sr).sin())
1207 .collect();
1208 let a = single_bin_amplitude(&sig, 1000.0, sr);
1209 assert!((a - ac).abs() < 1e-3, "DC pedestal must not affect AC amplitude; got {a}");
1210 }
1211
1212 /// The core point of the new metric: when the WDF is far BELOW the golden,
1213 /// `normalized_rms_error_db` DEGENERATES to ~0 dB ("shape matches") while
1214 /// `ac_gain_db` correctly reports the true ~-40 dB level gap.
1215 #[test]
1216 fn ac_gain_db_exposes_what_normalized_rms_hides() {
1217 let sr = 48000.0;
1218 let golden: Vec<f64> = (0..48000)
1219 .map(|i| (2.0 * std::f64::consts::PI * 1000.0 * i as f64 / sr).sin())
1220 .collect();
1221 // WDF is the same tone at 1% of the level (≈ -40 dB), e.g. an under-driven
1222 // input port.
1223 let wdf: Vec<f64> = golden.iter().map(|&x| 0.01 * x).collect();
1224
1225 let nrms = normalized_rms_error_db(&wdf, &golden);
1226 let gain = ac_gain_db(&wdf, &golden, 1000.0, sr);
1227
1228 // normalized RMS reads ~0 dB (degenerate: rms(wdf-ref) ≈ rms(ref)).
1229 assert!(
1230 nrms.abs() < 1.0,
1231 "normalized_rms_error_db is degenerate here (expected ~0 dB), got {nrms:.2}"
1232 );
1233 // ac_gain_db reports the real gap: 0.01 → -40 dB.
1234 assert!(
1235 (gain + 40.0).abs() < 0.5,
1236 "ac_gain_db must report the true -40 dB gap, got {gain:.2}"
1237 );
1238 }
1239
1240 #[test]
1241 fn ac_gain_db_doubling_is_plus_six_db_regardless_of_dc() {
1242 let sr = 48000.0;
1243 let golden: Vec<f64> = (0..48000)
1244 .map(|i| 0.1 * (2.0 * std::f64::consts::PI * 1000.0 * i as f64 / sr).sin())
1245 .collect();
1246 // WDF: double the AC amplitude AND add an unrelated DC bias.
1247 let wdf: Vec<f64> = golden.iter().map(|&x| 3.3 + 2.0 * x).collect();
1248 let gain = ac_gain_db(&wdf, &golden, 1000.0, sr);
1249 assert!((gain - 6.02).abs() < 0.1, "2x AC → +6 dB, got {gain:.2}");
1250 }
1251
1252 #[test]
1253 fn ac_amplitude_drift_zero_for_steady_large_for_ramp() {
1254 let sr = 48000.0;
1255 // Steady tone → ~0 dB drift.
1256 let steady: Vec<f64> = (0..48000)
1257 .map(|i| (2.0 * std::f64::consts::PI * 1000.0 * i as f64 / sr).sin())
1258 .collect();
1259 assert!(
1260 ac_amplitude_drift_db(&steady, 1000.0, sr).abs() < 0.2,
1261 "steady tone must show ~0 dB drift"
1262 );
1263 // Amplitude ramp (transient): envelope grows from 0.1 to 1.0 over the
1264 // window → second half is much louder than the first.
1265 let n = 48000usize;
1266 let ramp: Vec<f64> = (0..n)
1267 .map(|i| {
1268 let env = 0.1 + 0.9 * (i as f64 / n as f64);
1269 env * (2.0 * std::f64::consts::PI * 1000.0 * i as f64 / sr).sin()
1270 })
1271 .collect();
1272 let drift = ac_amplitude_drift_db(&ramp, 1000.0, sr);
1273 assert!(drift > 3.0, "ramping (unsettled) tone must show large drift, got {drift:.2}");
1274 }
1275
1276 #[test]
1277 fn thd_of_pure_sine_is_very_low() {
1278 let sr = 48000.0;
1279 let sig: Vec<f64> = (0..48000)
1280 .map(|i| (2.0 * std::f64::consts::PI * 1000.0 * i as f64 / sr).sin())
1281 .collect();
1282 let thd = thd_db(&sig, 1000.0, sr, 10);
1283 assert!(thd < -60.0, "Pure sine THD should be < -60dB, got {thd}");
1284 }
1285
1286 #[test]
1287 fn clipped_sine_has_high_thd() {
1288 let sr = 48000.0;
1289 let sig: Vec<f64> = (0..48000)
1290 .map(|i| {
1291 let x = (2.0 * std::f64::consts::PI * 1000.0 * i as f64 / sr).sin();
1292 x.clamp(-0.5, 0.5) // Hard clip at ±0.5
1293 })
1294 .collect();
1295 let thd = thd_db(&sig, 1000.0, sr, 10);
1296 assert!(thd > -20.0, "Clipped sine THD should be > -20dB, got {thd}");
1297 }
1298
1299 /// Verify that the noise-floor gate does not cause spurious large spectral
1300 /// errors when matching harmonics agree but one signal has a lower noise
1301 /// floor than the other (the "false 218 dB" single_diode scenario).
1302 ///
1303 /// Why this matters: ngspice's broadband numerical noise grass sits at
1304 /// roughly −95 dBFS. The WDF engine's numerical floor is much lower
1305 /// (≈ −316 dBFS). Without the absolute floor gate, bins in the ref that
1306 /// are just above ref_peak−80 dB (e.g. −99 dBFS) get compared against WDF
1307 /// bins at −316 dBFS, yielding ≈ 220 dB "error" — even though the real
1308 /// harmonics agree. The fix requires both ref and WDF to be above
1309 /// −100 dBFS before scoring the bin's error.
1310 #[test]
1311 fn spectral_error_ignores_noise_floor_mismatch() {
1312 let sr = 48000.0;
1313 let n = sr as usize; // 1 second
1314 let freq = 1000.0_f64;
1315
1316 // Reference: sine with the first several harmonics (simulating a
1317 // nonlinear SPICE output with noise grass at −95 dBFS).
1318 let ref_signal: Vec<f64> = (0..n)
1319 .map(|i| {
1320 let t = i as f64 / sr;
1321 let x = (2.0 * std::f64::consts::PI * freq * t).sin();
1322 // Simulate ngspice noise grass: 1e-5 amplitude ≈ −100 dBFS
1323 let noise_floor = 1e-5 * ((i as f64 * 7.13).sin());
1324 x + noise_floor
1325 })
1326 .collect();
1327
1328 // WDF: same sine at same level but with effectively zero noise floor
1329 // (pristine numerical output — no added noise grass).
1330 let wdf_signal: Vec<f64> = (0..n)
1331 .map(|i| {
1332 let t = i as f64 / sr;
1333 (2.0 * std::f64::consts::PI * freq * t).sin()
1334 })
1335 .collect();
1336
1337 let err = spectral_error_db(&wdf_signal, &ref_signal, sr, None);
1338
1339 // The two signals are identical except for noise grass below −100 dBFS.
1340 // The gate must exclude those bins, so the error must be small (< 3 dB).
1341 // Pre-fix this would report ≈ 100–200 dB because WDF's −316 dBFS bins
1342 // were scored against ref's −100 dBFS noise grass.
1343 assert!(
1344 err < 3.0,
1345 "Noise-floor mismatch should produce < 3 dB spectral error, got {err:.1} dB. \
1346 Check that the absolute noise-floor gate in spectral_error_db is working."
1347 );
1348 }
1349
1350 /// THD+N of a hard-clipped sine must be much higher than a clean sine.
1351 ///
1352 /// Why this matters: THD+N includes broadband noise and intermod across all
1353 /// bins, so a hard clipper that produces strong harmonics AND intermod
1354 /// noise should score significantly higher than a pure sine.
1355 #[test]
1356 fn thd_plus_n_clipped_sine_much_higher_than_clean() {
1357 let sr = 48000.0;
1358 let clean: Vec<f64> = (0..48000)
1359 .map(|i| (2.0 * std::f64::consts::PI * 1000.0 * i as f64 / sr).sin())
1360 .collect();
1361 let clipped: Vec<f64> = clean.iter().map(|&x| x.clamp(-0.3, 0.3)).collect();
1362
1363 let clean_tpn = thd_plus_n_db(&clean, 1000.0, sr);
1364 let clipped_tpn = thd_plus_n_db(&clipped, 1000.0, sr);
1365
1366 assert!(
1367 clipped_tpn > clean_tpn + 10.0,
1368 "Clipped sine THD+N ({clipped_tpn:.1} dB) should be >10 dB higher than clean ({clean_tpn:.1} dB)"
1369 );
1370 }
1371
1372 /// harmonic_mag_error_db ~0 when wdf == ref, large when one harmonic differs by ~20 dB.
1373 ///
1374 /// Why this matters: the metric must be sensitive to per-harmonic level
1375 /// mismatches that matter for circuit accuracy but immune to noise in bins
1376 /// where both signals are below −100 dBFS.
1377 #[test]
1378 fn harmonic_mag_error_zero_for_identical_large_for_mismatch() {
1379 let sr = 48000.0;
1380 let n = 48000_usize;
1381 let f = 1000.0_f64;
1382
1383 // Shared base: fundamental + three harmonics at known levels.
1384 let base: Vec<f64> = (0..n)
1385 .map(|i| {
1386 let t = i as f64 / sr;
1387 (2.0 * std::f64::consts::PI * f * t).sin()
1388 + 0.1 * (2.0 * std::f64::consts::PI * 2.0 * f * t).sin()
1389 + 0.05 * (2.0 * std::f64::consts::PI * 3.0 * f * t).sin()
1390 })
1391 .collect();
1392
1393 // Identical signals → error ~0.
1394 let err_same = harmonic_mag_error_db(&base, &base, f, sr, 10);
1395 assert!(
1396 err_same < 1.0,
1397 "Identical signals should give ~0 dB harmonic_mag_error, got {err_same:.2} dB"
1398 );
1399
1400 // WDF with 2nd harmonic reduced by ~20 dB (0.1 → 0.01).
1401 let wdf_altered: Vec<f64> = (0..n)
1402 .map(|i| {
1403 let t = i as f64 / sr;
1404 (2.0 * std::f64::consts::PI * f * t).sin()
1405 + 0.01 * (2.0 * std::f64::consts::PI * 2.0 * f * t).sin()
1406 + 0.05 * (2.0 * std::f64::consts::PI * 3.0 * f * t).sin()
1407 })
1408 .collect();
1409
1410 let err_altered = harmonic_mag_error_db(&wdf_altered, &base, f, sr, 10);
1411 assert!(
1412 err_altered >= 15.0,
1413 "~20 dB h2 level change should give >=15 dB harmonic_mag_error, got {err_altered:.2} dB"
1414 );
1415 }
1416
1417 /// even_odd_ratio_error_db ~0 for identical signals, large when symmetry differs.
1418 ///
1419 /// Why this matters: push-pull vs single-ended topologies differ in their
1420 /// even/odd balance; this metric captures that difference.
1421 #[test]
1422 fn even_odd_ratio_error_zero_same_large_when_symmetry_differs() {
1423 let sr = 48000.0;
1424 let n = 48000_usize;
1425 let f = 1000.0_f64;
1426
1427 // Asymmetric distortion: strong even harmonics (characteristic of single-ended).
1428 let asymmetric: Vec<f64> = (0..n)
1429 .map(|i| {
1430 let t = i as f64 / sr;
1431 (2.0 * std::f64::consts::PI * f * t).sin()
1432 + 0.3 * (2.0 * std::f64::consts::PI * 2.0 * f * t).sin()
1433 + 0.05 * (2.0 * std::f64::consts::PI * 3.0 * f * t).sin()
1434 })
1435 .collect();
1436
1437 // Symmetric distortion: strong odd harmonics only (characteristic of push-pull).
1438 let symmetric: Vec<f64> = (0..n)
1439 .map(|i| {
1440 let t = i as f64 / sr;
1441 (2.0 * std::f64::consts::PI * f * t).sin()
1442 + 0.01 * (2.0 * std::f64::consts::PI * 2.0 * f * t).sin()
1443 + 0.3 * (2.0 * std::f64::consts::PI * 3.0 * f * t).sin()
1444 })
1445 .collect();
1446
1447 // Identical pair → error ~0.
1448 let err_same = even_odd_ratio_error_db(&asymmetric, &asymmetric, f, sr, 10);
1449 assert!(
1450 err_same < 1.0,
1451 "Identical signals should give ~0 dB even_odd_ratio_error, got {err_same:.2} dB"
1452 );
1453
1454 // Asymmetric vs symmetric → error must be large.
1455 let err_diff = even_odd_ratio_error_db(&symmetric, &asymmetric, f, sr, 10);
1456 assert!(
1457 err_diff > 10.0,
1458 "Asymmetric vs symmetric should give >10 dB even_odd_ratio_error, got {err_diff:.2} dB"
1459 );
1460 }
1461
1462 /// Verify that a genuine harmonic level mismatch still scores a large spectral
1463 /// error even after the noise-floor fix (regression guard for the fix).
1464 ///
1465 /// Why this matters: the absolute floor at −100 dBFS should only exclude
1466 /// bins where *both* signals are near the noise floor. When both ref and
1467 /// WDF have strong, above-floor content at the same harmonic but at
1468 /// significantly different levels, the error must still score large.
1469 #[test]
1470 fn spectral_error_detects_real_harmonic_divergence() {
1471 let sr = 48000.0;
1472 let n = sr as usize;
1473 let freq = 1000.0_f64;
1474
1475 // Reference: fundamental + strong 2nd harmonic at −20 dBFS (amplitude 0.1).
1476 let ref_signal: Vec<f64> = (0..n)
1477 .map(|i| {
1478 let t = i as f64 / sr;
1479 let h1 = (2.0 * std::f64::consts::PI * freq * t).sin();
1480 let h2 = 0.1 * (2.0 * std::f64::consts::PI * 2.0 * freq * t).sin();
1481 h1 + h2
1482 })
1483 .collect();
1484
1485 // WDF: fundamental + 2nd harmonic 30 dB lower than ref (a real accuracy gap
1486 // where both signals have measurable content above the noise floor).
1487 // WDF h2 amplitude ≈ 0.1 * 10^(-30/20) ≈ 0.0032.
1488 let wdf_signal: Vec<f64> = (0..n)
1489 .map(|i| {
1490 let t = i as f64 / sr;
1491 let h1 = (2.0 * std::f64::consts::PI * freq * t).sin();
1492 let h2 = 0.0032 * (2.0 * std::f64::consts::PI * 2.0 * freq * t).sin();
1493 h1 + h2
1494 })
1495 .collect();
1496
1497 let err = spectral_error_db(&wdf_signal, &ref_signal, sr, None);
1498
1499 // The 2nd harmonic differs by ~30 dB between ref and WDF, and both
1500 // are above the −100 dBFS noise floor, so the error must score large.
1501 assert!(
1502 err > 20.0,
1503 "30 dB h2 level gap should produce > 20 dB spectral error, got {err:.1} dB."
1504 );
1505 }
1506
1507 // ── op_point bias-accuracy metric (ngspice-free, synthetic) ──────────────
1508 mod op_point_logic {
1509 use crate::metrics::op_point::*;
1510 use crate::spice::SpiceDeviceOp;
1511
1512 fn spice(dev: &str, vbe: f64, vce: f64, ic: f64) -> SpiceDeviceOp {
1513 SpiceDeviceOp {
1514 device: dev.into(),
1515 model: "qmod".into(),
1516 vbe,
1517 vce,
1518 ic,
1519 ib: ic / 100.0,
1520 gm: 0.03,
1521 }
1522 }
1523 fn ours(reference: &str, vbe: f64, vce: f64, ic: f64) -> DeviceBias {
1524 DeviceBias { reference: reference.into(), vbe, vce, ic }
1525 }
1526
1527 #[test]
1528 fn residual_near_zero_and_match_ok_is_bias_ok() {
1529 let c = OpPassCriteria::default();
1530 let r = evaluate(
1531 "x",
1532 &[ours("Q1", 0.6505, 1.10, 1.0e-3)],
1533 &[spice("Q1", 0.650, 1.10, 1.0e-3)],
1534 &[PortResidual { residual: 0.01, residual_full: 0.01 }],
1535 &c,
1536 );
1537 assert_eq!(r.verdict, Verdict::BiasOk);
1538 assert!(r.match_ok(&c) && r.formulation_ok(&c));
1539 }
1540
1541 #[test]
1542 fn residual_small_but_match_off_is_solver_off_layer_b() {
1543 // The BA283 signature: ngspice's .op IS a fixed point (small residual)
1544 // but our settled bias lands elsewhere (large ΔVbe / wrong Ic).
1545 let c = OpPassCriteria::default();
1546 let r = evaluate(
1547 "ba283-like",
1548 &[ours("Q1", 0.20, 19.9, 0.0)],
1549 &[spice("Q1", 0.61, 5.35, 0.26e-3)],
1550 &[PortResidual { residual: 0.027, residual_full: 0.027 }],
1551 &c,
1552 );
1553 assert_eq!(r.verdict, Verdict::FormulationOkSolverOff);
1554 assert!(r.formulation_ok(&c) && !r.match_ok(&c));
1555 }
1556
1557 #[test]
1558 fn large_residual_is_formulation_bug_layer_a() {
1559 let c = OpPassCriteria::default();
1560 let r = evaluate(
1561 "y",
1562 &[ours("Q1", 0.0, 0.0, 0.0)],
1563 &[spice("Q1", 0.0, 0.0, 0.0)],
1564 &[PortResidual { residual: 8.5, residual_full: 8.5 }],
1565 &c,
1566 );
1567 assert_eq!(r.verdict, Verdict::FormulationBug);
1568 }
1569
1570 #[test]
1571 fn match_compares_on_magnitude_for_pnp() {
1572 // PNP: our port Vbe is negative, ngspice reports positive magnitude.
1573 let dm = match_devices(
1574 &[ours("Q1", -0.62, -3.5, -1.0e-3)],
1575 &[spice("Q1", 0.62, 3.5, -1.0e-3)],
1576 );
1577 assert_eq!(dm.len(), 1);
1578 assert!(dm[0].d_vbe.abs() < 1e-9, "magnitude match ⇒ ΔVbe≈0");
1579 assert!(dm[0].d_ic_pct.abs() < 1e-6);
1580 }
1581 }
1582
1583 // ── ac_accuracy AC-fidelity metric (ngspice-free, synthetic) ─────────────
1584 mod ac_accuracy_logic {
1585 use crate::metrics::ac_accuracy::*;
1586
1587 const SR: f64 = 48_000.0;
1588 const F: f64 = 1_000.0;
1589
1590 /// A pure 1 kHz tone (+ optional harmonics) as a golden.
1591 fn tone(amp: f64, h2: f64, h3: f64) -> Vec<f64> {
1592 (0..48_000)
1593 .map(|i| {
1594 let t = i as f64 / SR;
1595 amp * ((2.0 * std::f64::consts::PI * F * t).sin()
1596 + h2 * (2.0 * std::f64::consts::PI * 2.0 * F * t).sin()
1597 + h3 * (2.0 * std::f64::consts::PI * 3.0 * F * t).sin())
1598 })
1599 .collect()
1600 }
1601
1602 /// THE core point: when the WDF differs from the golden ONLY by a scalar
1603 /// level, the verdict must be LevelOnly — the +N dB is a flat scalar and
1604 /// the gain-normalized shape residual is ~0 (what the degenerate
1605 /// `normalized_rms_error_db` could never separate).
1606 #[test]
1607 fn pure_level_offset_is_level_only_and_shape_clean() {
1608 let th = AcThresholds::default();
1609 let golden = tone(0.1, 0.02, 0.005);
1610 // WDF: same waveform SHAPE, +2.42 dB louder (the BA283 signature).
1611 let k = 10f64.powf(2.42 / 20.0);
1612 let wdf: Vec<f64> = golden.iter().map(|&x| k * x).collect();
1613 // Flat frequency response (same +2.42 dB at every frequency).
1614 let response: Vec<FreqPoint> = [50.0, 1000.0, 10000.0]
1615 .iter()
1616 .map(|&f| FreqPoint { freq_hz: f, gain_db: 2.42 })
1617 .collect();
1618
1619 let r = evaluate("scaled", &wdf, &golden, SR, F, response, &th);
1620 assert!((r.gain_db - 2.42).abs() < 0.05, "level = +2.42 dB, got {:.3}", r.gain_db);
1621 // Gain factored out ⇒ shape residual collapses.
1622 assert!(r.shape_rms_db < -60.0, "shape RMS must be clean, got {:.1}", r.shape_rms_db);
1623 assert!(r.shape_spectral_db < 1.0, "shape spectral clean, got {:.2}", r.shape_spectral_db);
1624 assert!(r.thd_error_db() < 1.0, "THD matches, got {:.2}", r.thd_error_db());
1625 assert!(r.max_harmonic_error_db() < 1.0, "harmonics match, got {:.2}", r.max_harmonic_error_db());
1626 assert!(r.response_tilt_db < 0.5, "response flat, got {:.2}", r.response_tilt_db);
1627 assert_eq!(r.verdict, AcVerdict::LevelOnly);
1628 }
1629
1630 /// A genuine shape difference (extra 2nd harmonic in the WDF) must NOT be
1631 /// hidden by gain normalization — verdict Shape or Harmonic error.
1632 #[test]
1633 fn added_harmonic_is_not_level_only() {
1634 let th = AcThresholds::default();
1635 let golden = tone(0.1, 0.0, 0.0); // clean sine
1636 let wdf = tone(0.1, 0.3, 0.0); // strong 2nd harmonic added
1637 let r = evaluate("distorted", &wdf, &golden, SR, F, vec![], &th);
1638 assert_ne!(r.verdict, AcVerdict::LevelOnly);
1639 assert_ne!(r.verdict, AcVerdict::Clean);
1640 assert!(
1641 matches!(r.verdict, AcVerdict::ShapeError | AcVerdict::HarmonicError),
1642 "added harmonic ⇒ Shape/Harmonic, got {:?}",
1643 r.verdict
1644 );
1645 }
1646
1647 /// A frequency-SHAPED gain (flat level fine at 1 kHz, but tilted across the
1648 /// sweep) must classify as ResponseError, not LevelOnly.
1649 #[test]
1650 fn frequency_shaped_response_is_response_error() {
1651 let th = AcThresholds::default();
1652 let golden = tone(0.1, 0.0, 0.0);
1653 let wdf: Vec<f64> = golden.clone(); // identical at 1 kHz (level+shape clean)
1654 let response: Vec<FreqPoint> = vec![
1655 FreqPoint { freq_hz: 50.0, gain_db: -8.0 },
1656 FreqPoint { freq_hz: 1000.0, gain_db: 0.0 },
1657 FreqPoint { freq_hz: 10000.0, gain_db: 2.0 },
1658 ];
1659 let r = evaluate("tilted", &wdf, &golden, SR, F, response, &th);
1660 assert!(r.response_tilt_db > 3.0, "tilt = {:.1} dB", r.response_tilt_db);
1661 assert_eq!(r.verdict, AcVerdict::ResponseError);
1662 }
1663
1664 /// Silence golden ⇒ NoData (no tone to grade against).
1665 #[test]
1666 fn silent_golden_is_no_data() {
1667 let th = AcThresholds::default();
1668 let golden = vec![0.0; 48_000];
1669 let wdf = tone(0.1, 0.0, 0.0);
1670 let r = evaluate("silent", &wdf, &golden, SR, F, vec![], &th);
1671 assert_eq!(r.verdict, AcVerdict::NoData);
1672 }
1673 }
1674}