Skip to main content

pedalkernel_validate/
analytical.rs

1//! Analytical reference generators for linear circuits.
2//!
3//! These provide mathematically exact responses for validating WDF
4//! against theory, without needing SPICE.
5
6use std::f64::consts::PI;
7
8/// First-order RC lowpass filter impulse response.
9///
10/// The continuous-time impulse response is:
11///   h(t) = (1/τ) * exp(-t/τ)  for t ≥ 0
12/// where τ = RC.
13///
14/// For a digital system, we sample this and normalize to get
15/// the discrete impulse response that, when convolved with input,
16/// gives the lowpass-filtered output.
17pub fn rc_lowpass_impulse_response(
18    r_ohms: f64,
19    c_farads: f64,
20    sample_rate: f64,
21    n_samples: usize,
22) -> Vec<f64> {
23    let tau = r_ohms * c_farads;
24    let dt = 1.0 / sample_rate;
25
26    // The bilinear transform gives us the exact discrete-time
27    // equivalent. For a first-order lowpass:
28    //   H(s) = 1 / (1 + s*τ)
29    // Bilinear: s = (2/T) * (1 - z^-1) / (1 + z^-1)
30    //
31    // This gives: H(z) = (b0 + b1*z^-1) / (1 + a1*z^-1)
32    // where:
33    //   K = 2 / T
34    //   b0 = b1 = 1 / (1 + K*τ)
35    //   a1 = (1 - K*τ) / (1 + K*τ)
36
37    let k = 2.0 / dt;
38    let denom = 1.0 + k * tau;
39    let b0 = 1.0 / denom;
40    let b1 = b0;
41    let a1 = (1.0 - k * tau) / denom;
42
43    // Generate impulse response by feeding impulse through the filter
44    let mut output = Vec::with_capacity(n_samples);
45    let mut x_prev = 0.0;
46    let mut y_prev = 0.0;
47
48    for i in 0..n_samples {
49        let x = if i == 0 { 1.0 } else { 0.0 };
50        let y = b0 * x + b1 * x_prev - a1 * y_prev;
51        output.push(y);
52        x_prev = x;
53        y_prev = y;
54    }
55
56    output
57}
58
59/// First-order RC lowpass frequency response magnitude at given frequency.
60///
61/// |H(f)| = 1 / sqrt(1 + (f/fc)^2)
62/// where fc = 1 / (2*pi*R*C)
63pub fn rc_lowpass_magnitude(r_ohms: f64, c_farads: f64, freq_hz: f64) -> f64 {
64    let fc = 1.0 / (2.0 * PI * r_ohms * c_farads);
65    1.0 / (1.0 + (freq_hz / fc).powi(2)).sqrt()
66}
67
68/// First-order RC lowpass phase response at given frequency.
69///
70/// phase(f) = -atan(f/fc)
71pub fn rc_lowpass_phase(r_ohms: f64, c_farads: f64, freq_hz: f64) -> f64 {
72    let fc = 1.0 / (2.0 * PI * r_ohms * c_farads);
73    -(freq_hz / fc).atan()
74}
75
76/// First-order RC highpass filter impulse response.
77///
78/// The continuous-time transfer function is:
79///   H(s) = sRC / (1 + sRC)
80/// where τ = RC.
81///
82/// Bilinear transform: s = (2/T) * (1 - z^-1) / (1 + z^-1)
83/// This gives: H(z) = (b0 + b1*z^-1) / (1 + a1*z^-1)
84/// where:
85///   K = 2/T, Kτ = K*τ
86///   b0 = Kτ / (1 + Kτ)
87///   b1 = -Kτ / (1 + Kτ) = -b0
88///   a1 = (1 - Kτ) / (1 + Kτ)
89pub fn rc_highpass_impulse_response(
90    r_ohms: f64,
91    c_farads: f64,
92    sample_rate: f64,
93    n_samples: usize,
94) -> Vec<f64> {
95    let tau = r_ohms * c_farads;
96    let dt = 1.0 / sample_rate;
97
98    let k = 2.0 / dt;
99    let kt = k * tau;
100    let denom = 1.0 + kt;
101    let b0 = kt / denom;
102    let b1 = -b0;
103    let a1 = (1.0 - kt) / denom;
104
105    let mut output = Vec::with_capacity(n_samples);
106    let mut x_prev = 0.0;
107    let mut y_prev = 0.0;
108
109    for i in 0..n_samples {
110        let x = if i == 0 { 1.0 } else { 0.0 };
111        let y = b0 * x + b1 * x_prev - a1 * y_prev;
112        output.push(y);
113        x_prev = x;
114        y_prev = y;
115    }
116
117    output
118}
119
120/// Process a signal through an ideal first-order RC highpass.
121///
122/// Uses bilinear transform for exact discrete-time response.
123/// H(s) = sRC / (1 + sRC)
124pub fn rc_highpass_filter(
125    signal: &[f64],
126    r_ohms: f64,
127    c_farads: f64,
128    sample_rate: f64,
129) -> Vec<f64> {
130    let tau = r_ohms * c_farads;
131    let dt = 1.0 / sample_rate;
132
133    let k = 2.0 / dt;
134    let kt = k * tau;
135    let denom = 1.0 + kt;
136    let b0 = kt / denom;
137    let b1 = -b0;
138    let a1 = (1.0 - kt) / denom;
139
140    let mut output = Vec::with_capacity(signal.len());
141    let mut x_prev = 0.0;
142    let mut y_prev = 0.0;
143
144    for &x in signal {
145        let y = b0 * x + b1 * x_prev - a1 * y_prev;
146        output.push(y);
147        x_prev = x;
148        y_prev = y;
149    }
150
151    output
152}
153
154/// Series RLC bandpass filter.
155///
156/// For a series RLC circuit with output across the load resistor RL
157/// in the topology: Vin -> R_series -> L -> C -> RL -> GND
158///
159/// The transfer function considering the load resistor is:
160///   H(s) = RL / (R + sL + 1/(sC) + RL)
161///        = s * RL * C / (s^2 * L * C + s * (R + RL) * C + 1)
162///
163/// This is a bandpass with:
164///   f0 = 1 / (2*pi*sqrt(L*C))
165///   Q = sqrt(L/C) / (R + RL)
166///
167/// Uses bilinear transform to convert to discrete-time biquad.
168pub fn rlc_bandpass_filter(
169    signal: &[f64],
170    r_ohms: f64,
171    l_henrys: f64,
172    c_farads: f64,
173    r_load: f64,
174    sample_rate: f64,
175) -> Vec<f64> {
176    let dt = 1.0 / sample_rate;
177
178    // Continuous-time coefficients for H(s) = (RL*C*s) / (L*C*s^2 + (R+RL)*C*s + 1)
179    // In standard biquad form: H(s) = (b_s * s) / (a2 * s^2 + a1 * s + a0)
180    let b_s = r_load * c_farads;
181    let a2 = l_henrys * c_farads;
182    let a1 = (r_ohms + r_load) * c_farads;
183    let a0 = 1.0;
184
185    // Bilinear transform: s = (2/T)(1 - z^-1)/(1 + z^-1)
186    let k = 2.0 / dt;
187    let k2 = k * k;
188
189    // Substitute into H(s) and collect z^-1, z^-2 terms
190    // Numerator: b_s * k * (1 - z^-1) / (1 + z^-1)
191    // Denominator: a2 * k^2 * (1-z^-1)^2/(1+z^-1)^2 + a1 * k * (1-z^-1)/(1+z^-1) + a0
192    //
193    // Multiply through by (1+z^-1)^2:
194    // Num: b_s * k * (1 - z^-1)(1 + z^-1) = b_s * k * (1 - z^-2)
195    // Den: a2 * k^2 * (1 - z^-1)^2 + a1 * k * (1 - z^-1)(1 + z^-1) + a0 * (1 + z^-1)^2
196    //    = a2*k2*(1 - 2z^-1 + z^-2) + a1*k*(1 - z^-2) + a0*(1 + 2z^-1 + z^-2)
197
198    let d0 = a2 * k2 + a1 * k + a0;
199    let d1 = -2.0 * a2 * k2 + 2.0 * a0;
200    let d2 = a2 * k2 - a1 * k + a0;
201
202    let n0 = b_s * k;
203    // n1 = 0 (the z^-1 coefficient of (1 - z^-2) is 0)
204    let n2 = -b_s * k;
205
206    // Normalize by d0
207    let b0 = n0 / d0;
208    let b1_coeff = 0.0; // n1/d0
209    let b2 = n2 / d0;
210    let a1_coeff = d1 / d0;
211    let a2_coeff = d2 / d0;
212
213    // Apply biquad: y[n] = b0*x[n] + b1*x[n-1] + b2*x[n-2] - a1*y[n-1] - a2*y[n-2]
214    let mut output = Vec::with_capacity(signal.len());
215    let mut x1 = 0.0;
216    let mut x2 = 0.0;
217    let mut y1 = 0.0;
218    let mut y2 = 0.0;
219
220    for &x in signal {
221        let y = b0 * x + b1_coeff * x1 + b2 * x2 - a1_coeff * y1 - a2_coeff * y2;
222        output.push(y);
223        x2 = x1;
224        x1 = x;
225        y2 = y1;
226        y1 = y;
227    }
228
229    output
230}
231
232/// Twin-T notch filter.
233///
234/// Classic Twin-T topology with R, R, R/2, C, C, 2C.
235/// The ideal transfer function (with infinite impedance output) is:
236///   H(s) = (s^2*R^2*C^2 + 1) / (s^2*R^2*C^2 + 4*s*R*C + 1)
237///
238/// This gives a notch at f_notch = 1/(2*pi*R*C) with notch Q determined
239/// by the topology. The load resistor RL modifies the depth of the notch.
240///
241/// For the ideal case (no load), uses bilinear transform to biquad IIR.
242pub fn twin_t_notch_filter(
243    signal: &[f64],
244    r_ohms: f64,
245    c_farads: f64,
246    sample_rate: f64,
247) -> Vec<f64> {
248    let dt = 1.0 / sample_rate;
249    let rc = r_ohms * c_farads;
250    let rc2 = rc * rc;
251
252    // H(s) = (rc2 * s^2 + 1) / (rc2 * s^2 + 4*rc*s + 1)
253    // Numerator: a2n*s^2 + a0n = rc2*s^2 + 1
254    // Denominator: a2d*s^2 + a1d*s + a0d = rc2*s^2 + 4*rc*s + 1
255
256    let k = 2.0 / dt;
257    let k2 = k * k;
258
259    // Bilinear substitution and multiply by (1+z^-1)^2:
260    // s^2 -> k^2 * (1 - z^-1)^2 / (1 + z^-1)^2
261    // s -> k * (1 - z^-1) / (1 + z^-1)
262    //
263    // Numerator * (1+z^-1)^2:
264    //   rc2 * k2 * (1-z^-1)^2 + 1 * (1+z^-1)^2
265    //   = (rc2*k2 + 1) + (-2*rc2*k2 + 2)*z^-1 + (rc2*k2 + 1)*z^-2
266    //
267    // Denominator * (1+z^-1)^2:
268    //   rc2 * k2 * (1-z^-1)^2 + 4*rc*k * (1-z^-1)(1+z^-1) + 1*(1+z^-1)^2
269    //   = (rc2*k2 + 4*rc*k + 1) + (-2*rc2*k2 + 2)*z^-1 + (rc2*k2 - 4*rc*k + 1)*z^-2
270
271    let n0 = rc2 * k2 + 1.0;
272    let n1 = -2.0 * rc2 * k2 + 2.0;
273    let n2 = rc2 * k2 + 1.0;
274
275    let d0 = rc2 * k2 + 4.0 * rc * k + 1.0;
276    let d1 = -2.0 * rc2 * k2 + 2.0;
277    let d2 = rc2 * k2 - 4.0 * rc * k + 1.0;
278
279    // Normalize
280    let b0 = n0 / d0;
281    let b1_coeff = n1 / d0;
282    let b2 = n2 / d0;
283    let a1_coeff = d1 / d0;
284    let a2_coeff = d2 / d0;
285
286    let mut output = Vec::with_capacity(signal.len());
287    let mut x1 = 0.0;
288    let mut x2 = 0.0;
289    let mut y1 = 0.0;
290    let mut y2 = 0.0;
291
292    for &x in signal {
293        let y = b0 * x + b1_coeff * x1 + b2 * x2 - a1_coeff * y1 - a2_coeff * y2;
294        output.push(y);
295        x2 = x1;
296        x1 = x;
297        y2 = y1;
298        y1 = y;
299    }
300
301    output
302}
303
304/// Process a signal through an ideal first-order RC lowpass.
305///
306/// Uses bilinear transform for exact discrete-time response.
307pub fn rc_lowpass_filter(signal: &[f64], r_ohms: f64, c_farads: f64, sample_rate: f64) -> Vec<f64> {
308    let tau = r_ohms * c_farads;
309    let dt = 1.0 / sample_rate;
310
311    let k = 2.0 / dt;
312    let denom = 1.0 + k * tau;
313    let b0 = 1.0 / denom;
314    let b1 = b0;
315    let a1 = (1.0 - k * tau) / denom;
316
317    let mut output = Vec::with_capacity(signal.len());
318    let mut x_prev = 0.0;
319    let mut y_prev = 0.0;
320
321    for &x in signal {
322        let y = b0 * x + b1 * x_prev - a1 * y_prev;
323        output.push(y);
324        x_prev = x;
325        y_prev = y;
326    }
327
328    output
329}
330
331/// First-order RL lowpass frequency response magnitude at given frequency.
332///
333/// For RL lowpass: L in series with input, R to ground (output across R)
334/// |H(f)| = 1 / sqrt(1 + (f/fc)^2)
335/// where fc = R / (2*pi*L)
336pub fn rl_lowpass_magnitude(l_henrys: f64, r_ohms: f64, freq_hz: f64) -> f64 {
337    let fc = r_ohms / (2.0 * PI * l_henrys);
338    1.0 / (1.0 + (freq_hz / fc).powi(2)).sqrt()
339}
340
341/// Process a signal through an ideal first-order RL lowpass.
342///
343/// Uses bilinear transform for exact discrete-time response.
344/// H(s) = R / (R + sL) = 1 / (1 + sL/R)
345/// Time constant τ = L/R (same form as RC lowpass with τ = RC)
346///
347/// This gives fc = R / (2*pi*L)
348pub fn rl_lowpass_filter(signal: &[f64], l_henrys: f64, r_ohms: f64, sample_rate: f64) -> Vec<f64> {
349    // Time constant τ = L/R
350    let tau = l_henrys / r_ohms;
351    let dt = 1.0 / sample_rate;
352
353    // Bilinear transform coefficients (same form as RC lowpass)
354    // α = 2*fs*τ
355    let k = 2.0 / dt;
356    let denom = 1.0 + k * tau;
357    let b0 = 1.0 / denom;
358    let b1 = b0;
359    let a1 = (1.0 - k * tau) / denom;
360
361    let mut output = Vec::with_capacity(signal.len());
362    let mut x_prev = 0.0;
363    let mut y_prev = 0.0;
364
365    for &x in signal {
366        let y = b0 * x + b1 * x_prev - a1 * y_prev;
367        output.push(y);
368        x_prev = x;
369        y_prev = y;
370    }
371
372    output
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    #[test]
380    fn rc_lowpass_impulse_decays() {
381        let r = 10_000.0; // 10k
382        let c = 10e-9; // 10nF
383        let sr = 96000.0;
384        let ir = rc_lowpass_impulse_response(r, c, sr, 1000);
385
386        // First sample should be positive
387        assert!(ir[0] > 0.0);
388
389        // Find peak (bilinear transform may have peak after first sample)
390        let peak_idx = ir
391            .iter()
392            .enumerate()
393            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
394            .map(|(i, _)| i)
395            .unwrap();
396
397        // Peak should be early (within first 10 samples)
398        assert!(
399            peak_idx < 10,
400            "Peak should be near the start, got idx {}",
401            peak_idx
402        );
403
404        // After peak, should decay monotonically
405        for i in (peak_idx + 1)..ir.len() {
406            assert!(
407                ir[i] <= ir[i - 1] + 1e-10, // Allow tiny numerical tolerance
408                "Impulse response should decay after peak at sample {}",
409                i
410            );
411        }
412
413        // Should approach zero by end
414        let peak_val = ir[peak_idx];
415        assert!(ir[999] < peak_val * 0.01, "Should decay to <1% of peak");
416    }
417
418    #[test]
419    fn rc_lowpass_cutoff_is_3db() {
420        let r = 10_000.0;
421        let c = 10e-9;
422        let fc = 1.0 / (2.0 * PI * r * c); // ~1591 Hz
423
424        let mag_at_fc = rc_lowpass_magnitude(r, c, fc);
425        let db_at_fc = 20.0 * mag_at_fc.log10();
426
427        // Should be -3dB at cutoff
428        assert!(
429            (db_at_fc + 3.0).abs() < 0.1,
430            "Magnitude at fc should be -3dB, got {db_at_fc}"
431        );
432    }
433
434    #[test]
435    fn rc_lowpass_passband_is_unity() {
436        let r = 10_000.0;
437        let c = 10e-9;
438
439        let mag_dc = rc_lowpass_magnitude(r, c, 0.0);
440        assert!((mag_dc - 1.0).abs() < 1e-10, "DC gain should be unity");
441    }
442
443    #[test]
444    fn rc_highpass_blocks_dc() {
445        let r = 33_000.0;
446        let c = 22e-9;
447        let sr = 96000.0;
448
449        // Feed DC signal through highpass — should output zero after transient
450        let dc_input = vec![1.0; 10000];
451        let output = rc_highpass_filter(&dc_input, r, c, sr);
452
453        // Last sample should be very close to zero (DC blocked)
454        assert!(
455            output.last().unwrap().abs() < 1e-3,
456            "Highpass should block DC, got {}",
457            output.last().unwrap()
458        );
459    }
460
461    #[test]
462    fn rc_highpass_passes_high_freq() {
463        let r = 33_000.0;
464        let c = 22e-9;
465        let sr = 96000.0;
466        let fc = 1.0 / (2.0 * PI * r * c); // ~219 Hz
467
468        // Test with sine well above cutoff (10x fc)
469        let f_test = fc * 10.0;
470        let duration = 0.1;
471        let n = (sr * duration) as usize;
472        let input: Vec<f64> = (0..n)
473            .map(|i| (2.0 * PI * f_test * i as f64 / sr).sin())
474            .collect();
475
476        let output = rc_highpass_filter(&input, r, c, sr);
477
478        // RMS of output should be close to RMS of input (near unity gain)
479        let in_rms: f64 = (input.iter().map(|x| x * x).sum::<f64>() / n as f64).sqrt();
480        let out_rms: f64 = (output.iter().map(|x| x * x).sum::<f64>() / n as f64).sqrt();
481        let ratio = out_rms / in_rms;
482
483        assert!(
484            ratio > 0.95,
485            "Highpass should pass high frequencies, ratio = {}",
486            ratio
487        );
488    }
489
490    #[test]
491    fn rlc_bandpass_peaks_at_resonance() {
492        // Use smaller R_load to get higher Q for a clearer bandpass peak
493        let r = 100.0_f64;
494        let l = 10e-3_f64;
495        let c = 100e-9_f64;
496        let r_load = 100.0_f64; // Small load for higher Q
497        let sr = 96000.0 * 4.0; // High sample rate for accuracy
498        let f0 = 1.0 / (2.0 * PI * (l * c).sqrt()); // ~5033 Hz
499        let duration = 0.1;
500        let n = (sr * duration) as usize;
501
502        // Test at resonance
503        let input_res: Vec<f64> = (0..n)
504            .map(|i| (2.0 * PI * f0 * i as f64 / sr).sin())
505            .collect();
506        let output_res = rlc_bandpass_filter(&input_res, r, l, c, r_load, sr);
507
508        // Test well below resonance (f0/20)
509        let f_low = f0 / 20.0;
510        let input_low: Vec<f64> = (0..n)
511            .map(|i| (2.0 * PI * f_low * i as f64 / sr).sin())
512            .collect();
513        let output_low = rlc_bandpass_filter(&input_low, r, l, c, r_load, sr);
514
515        // Use second half to avoid transients
516        let half = n / 2;
517        let rms_res: f64 =
518            (output_res[half..].iter().map(|x| x * x).sum::<f64>() / (n - half) as f64).sqrt();
519        let rms_low: f64 =
520            (output_low[half..].iter().map(|x| x * x).sum::<f64>() / (n - half) as f64).sqrt();
521
522        assert!(
523            rms_res > rms_low * 1.5,
524            "Response at resonance ({:.3}) should be greater than at {:.0}Hz ({:.3})",
525            rms_res,
526            f_low,
527            rms_low
528        );
529    }
530
531    #[test]
532    fn twin_t_notch_at_design_frequency() {
533        let r = 10_000.0;
534        let c = 10e-9;
535        let sr = 96000.0 * 4.0;
536        let f_notch = 1.0 / (2.0 * PI * r * c); // ~1591 Hz
537        let duration = 0.1;
538        let n = (sr * duration) as usize;
539
540        // Test at notch frequency
541        let input_notch: Vec<f64> = (0..n)
542            .map(|i| (2.0 * PI * f_notch * i as f64 / sr).sin())
543            .collect();
544        let output_notch = twin_t_notch_filter(&input_notch, r, c, sr);
545
546        // Test away from notch (5x)
547        let f_pass = f_notch * 5.0;
548        let input_pass: Vec<f64> = (0..n)
549            .map(|i| (2.0 * PI * f_pass * i as f64 / sr).sin())
550            .collect();
551        let output_pass = twin_t_notch_filter(&input_pass, r, c, sr);
552
553        // Use second half
554        let half = n / 2;
555        let rms_notch: f64 =
556            (output_notch[half..].iter().map(|x| x * x).sum::<f64>() / (n - half) as f64).sqrt();
557        let rms_pass: f64 =
558            (output_pass[half..].iter().map(|x| x * x).sum::<f64>() / (n - half) as f64).sqrt();
559
560        // At the notch, output should be much smaller
561        assert!(
562            rms_notch < rms_pass * 0.1,
563            "Notch response ({:.6}) should be much less than passband ({:.6})",
564            rms_notch,
565            rms_pass
566        );
567    }
568}