1use crate::grid::{SR_F, design_freqs};
6use faer::Mat;
7use secs_dsp::{
8 Complex64 as C, optimize, response,
9 smooth::{self, Fraction},
10};
11use secs_types::{chain::Peq, target::TargetCurve};
12use std::{cell::RefCell, f64::consts::PI};
13
14#[derive(Debug, Clone)]
17pub struct FitRequest<'a> {
18 pub g: &'a [f64],
19 pub corr_db: &'a [f64],
20 pub n: usize,
21 pub lo: f64,
22 pub hi: f64,
23 pub boost: f64,
24 pub ring_ms: Option<f64>,
25 pub f_max: Option<f64>,
26 pub bands: Option<&'a [(f64, f64)]>,
29}
30
31impl FitRequest<'_> {
32 fn in_range(&self, f: f64) -> bool {
33 f >= self.lo && f <= self.hi && self.bands.is_none_or(|bands| crate::rc::in_bands(f, bands))
34 }
35}
36
37pub type Fitter<'f> = dyn FnMut(&FitRequest<'_>) -> Vec<Peq> + 'f;
40
41#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
43pub enum PeqDerivatives {
44 #[default]
46 TwoPoint,
47 Analytic,
49}
50
51pub fn ring_q_cap(f0: f64, gain_db: f64, ring_ms: f64) -> f64 {
52 PI * f0 * ring_ms / 1000.0 / 10_f64.powf(gain_db / 40.0)
53}
54
55pub(crate) fn ring_boxes(bands: &[Peq], lb: &mut [f64], ub: &mut [f64], ring_ms: f64) {
56 for (i, &(f0, _, gain)) in bands.iter().enumerate() {
57 let k = 3 * i;
58 lb[k] = lb[k].max((f0 / 1.2).ln());
59 ub[k] = ub[k].min((f0 * 1.2).ln()).max(lb[k] + 1e-3);
60 if gain > 0.0 {
61 lb[k + 2] = 0.0;
62 ub[k + 2] = (gain + 3.0).min(ub[k + 2]);
63 } else {
64 ub[k + 2] = (gain / 2.0).min(-0.25);
65 }
66 ub[k + 1] = ub[k + 1]
67 .exp()
68 .min(ring_q_cap(f0 / 1.2, ub[k + 2], ring_ms))
69 .max(0.31)
70 .ln();
71 }
72}
73
74pub(crate) struct Seed {
75 pub bands: Vec<Peq>,
76 pub x0: Vec<f64>,
77 pub lb: Vec<f64>,
78 pub ub: Vec<f64>,
79}
80
81pub(crate) fn seed(req: &FitRequest<'_>) -> Seed {
82 assert_eq!(req.g.len(), req.corr_db.len());
83 let mut bands = Vec::new();
84 for _ in 0..req.n {
85 let current = response::peq_response(req.g, &bands);
86 let r: Vec<_> = req
87 .g
88 .iter()
89 .zip(req.corr_db)
90 .zip(current)
91 .map(|((&g, &c), h)| {
92 if req.in_range(g) {
93 c - 20.0 * (response::magnitude(h) + 1e-12).log10()
94 } else {
95 0.0
96 }
97 })
98 .collect();
99 let k = argmax(&r.iter().map(|v| v.abs()).collect::<Vec<_>>());
100 if r[k].abs() < 0.5 {
101 break;
102 }
103 let gain = r[k].clamp(-25.0, req.boost);
104 let half = r[k].abs() / 2.0;
105 let same = |i: usize| r[i].abs() >= half && r[i].signum() == r[k].signum();
106 let mut i0 = k;
107 let mut i1 = k;
108 while i0 > 0 && same(i0 - 1) {
109 i0 -= 1;
110 }
111 while i1 + 1 < r.len() && same(i1 + 1) {
112 i1 += 1;
113 }
114 let f0 = req.g[k];
115 let mut q = (f0 / (req.g[i1] - req.g[i0]).max(f0 * 0.02)).clamp(0.5, 15.0);
116 if let Some(ms) = req.ring_ms {
117 q = q.min(ring_q_cap(f0, gain, ms)).max(0.31);
118 }
119 bands.push((f0, q, gain));
120 }
121 let fmax = req.f_max.unwrap_or(2000.0_f64.max(req.hi * 1.2));
122 let mut lb = [15_f64.ln(), 0.3_f64.ln(), -25.0].repeat(bands.len());
123 let mut ub = [fmax.ln(), 20_f64.ln(), req.boost].repeat(bands.len());
124 if let Some(ms) = req.ring_ms {
125 ring_boxes(&bands, &mut lb, &mut ub, ms);
126 }
127 if let Some(regions) = req.bands {
130 for (i, &(f, _, _)) in bands.iter().enumerate() {
131 let &(lo, hi) = regions
132 .iter()
133 .find(|&&(lo, hi)| f >= lo && f <= hi)
134 .expect("seed belongs to a correction band");
135 lb[3 * i] = lb[3 * i].max(lo.ln());
136 ub[3 * i] = ub[3 * i].min(hi.ln());
137 }
138 }
139 let x0 = bands
140 .iter()
141 .flat_map(|&(f, q, g)| [f.ln(), q.ln(), g])
142 .enumerate()
143 .map(|(i, x)| {
144 let margin = if req.bands.is_some() {
145 1e-6_f64.min((ub[i] - lb[i]) / 4.0)
146 } else {
147 1e-6
148 };
149 x.max(lb[i] + margin).min(ub[i] - margin)
150 })
151 .collect();
152 Seed { bands, x0, lb, ub }
153}
154
155pub(crate) fn residual(req: &FitRequest<'_>, x: &[f64]) -> Vec<f64> {
156 let bands: Vec<_> = x
157 .as_chunks::<3>()
158 .0
159 .iter()
160 .map(|b| (b[0].exp(), b[1].exp(), b[2]))
161 .collect();
162 weighted_residual(req, response::peq_response(req.g, &bands))
163}
164
165fn weighted_residual(req: &FitRequest<'_>, h: Vec<C>) -> Vec<f64> {
166 h.into_iter()
167 .enumerate()
168 .map(|(i, h)| {
169 let w = if req.in_range(req.g[i]) { 1.0 } else { 0.15 };
170 let value = w * (20.0 * (response::magnitude(h) + 1e-12).log10() - req.corr_db[i]);
171 if value.is_finite() { value } else { 1e12 }
172 })
173 .collect()
174}
175
176pub(crate) struct ResidualCache {
179 z: Vec<C>,
180 bands: Vec<Option<([u64; 3], Vec<C>)>>,
181}
182
183impl ResidualCache {
184 pub(crate) fn new(g: &[f64], bands: usize) -> Self {
185 Self {
186 z: g.iter()
187 .map(|&f| C::from_polar(1.0, (-2.0 * PI * f) * (1.0 / 48000.0)))
188 .collect(),
189 bands: vec![None; bands],
190 }
191 }
192
193 pub(crate) fn evaluate(&mut self, req: &FitRequest<'_>, x: &[f64]) -> Vec<f64> {
194 let mut h = vec![C::new(1.0, 0.0); self.z.len()];
195 for (cached, b) in self.bands.iter_mut().zip(x.as_chunks::<3>().0) {
196 let key = b.map(f64::to_bits);
197 if cached.as_ref().is_none_or(|(old, _)| *old != key) {
198 *cached = Some((
199 key,
200 response::peaking_response_z(&self.z, b[0].exp(), b[1].exp(), b[2], 48000.0),
201 ));
202 }
203 for (h, p) in h.iter_mut().zip(&cached.as_ref().unwrap().1) {
204 *h *= p;
205 }
206 }
207 weighted_residual(req, h)
208 }
209}
210
211struct AnalyticBand {
212 key: [u64; 3],
213 response: Vec<C>,
214 logarithmic_derivatives: Option<[Vec<C>; 3]>,
215}
216
217struct AnalyticResidualCache {
220 z: Vec<C>,
221 bands: Vec<Option<AnalyticBand>>,
222 key: Vec<u64>,
223 cascade: Vec<C>,
224}
225
226impl AnalyticResidualCache {
227 fn new(g: &[f64], bands: usize) -> Self {
228 Self {
229 z: g.iter()
230 .map(|&f| C::from_polar(1.0, (-2.0 * PI * f) * (1.0 / 48000.0)))
231 .collect(),
232 bands: (0..bands).map(|_| None).collect(),
233 key: Vec::new(),
234 cascade: Vec::new(),
235 }
236 }
237
238 fn response(&mut self, x: &[f64]) {
239 if self.key.len() == x.len()
240 && self.key.iter().zip(x).all(|(&a, &b)| a == b.to_bits())
241 && !self.cascade.is_empty()
242 {
243 return;
244 }
245 self.cascade.clear();
246 self.cascade.resize(self.z.len(), C::new(1.0, 0.0));
247 for (cached, b) in self.bands.iter_mut().zip(x.as_chunks::<3>().0) {
248 let key = b.map(f64::to_bits);
249 if cached.as_ref().is_none_or(|old| old.key != key) {
250 *cached = Some(AnalyticBand {
251 key,
252 response: response::peaking_response_z(
253 &self.z,
254 b[0].exp(),
255 b[1].exp(),
256 b[2],
257 48000.0,
258 ),
259 logarithmic_derivatives: None,
260 });
261 }
262 for (h, p) in self
263 .cascade
264 .iter_mut()
265 .zip(&cached.as_ref().unwrap().response)
266 {
267 *h *= p;
268 }
269 }
270 self.key.clear();
271 self.key.extend(x.iter().map(|v| v.to_bits()));
272 }
273
274 fn evaluate(&mut self, req: &FitRequest<'_>, x: &[f64]) -> Vec<f64> {
275 self.response(x);
276 weighted_residual(req, self.cascade.clone())
277 }
278
279 fn jacobian(&mut self, req: &FitRequest<'_>, x: &[f64]) -> Mat<f64> {
280 self.response(x);
281 let factors: Vec<_> = self
282 .cascade
283 .iter()
284 .enumerate()
285 .map(|(i, &h)| {
286 let weight = if req.in_range(req.g[i]) { 1.0 } else { 0.15 };
287 let magnitude = response::magnitude(h);
288 weight * (20.0 / 10_f64.ln()) * magnitude / (magnitude + 1e-12)
289 })
290 .collect();
291 let mut jac = Mat::zeros(req.g.len(), x.len());
292 for (k, (cached, b)) in self.bands.iter_mut().zip(x.as_chunks::<3>().0).enumerate() {
293 let cached = cached.as_mut().unwrap();
294 let derivatives = cached.logarithmic_derivatives.get_or_insert_with(|| {
295 response::peaking_log_derivatives_z(&self.z, b[0].exp(), b[1].exp(), b[2], 48000.0)
296 });
297 for c in 0..3 {
298 for i in 0..req.g.len() {
299 jac[(i, 3 * k + c)] = factors[i] * derivatives[c][i].re;
300 }
301 }
302 }
303 jac
304 }
305}
306
307pub(crate) fn result_bands(req: &FitRequest<'_>, x: &[f64]) -> Vec<Peq> {
308 x.as_chunks::<3>()
309 .0
310 .iter()
311 .map(|b| {
312 let (f, mut q, gain) = (b[0].exp(), b[1].exp(), b[2]);
313 if let Some(ms) = req.ring_ms {
314 q = q.min(ring_q_cap(f, gain, ms));
315 }
316 (f, q, gain)
317 })
318 .collect()
319}
320
321pub fn fit_peq(req: &FitRequest<'_>) -> Vec<Peq> {
324 fit_peq_until(req, &|| false)
325}
326
327pub fn fit_peq_until(req: &FitRequest<'_>, cancelled: &dyn Fn() -> bool) -> Vec<Peq> {
333 fit_peq_with(req, cancelled, PeqDerivatives::TwoPoint)
334}
335
336pub fn fit_peq_with(
338 req: &FitRequest<'_>,
339 cancelled: &dyn Fn() -> bool,
340 derivatives: PeqDerivatives,
341) -> Vec<Peq> {
342 let seed = seed(req);
343 if seed.bands.is_empty() {
344 return Vec::new();
345 }
346 let max_nfev = if req.ring_ms.is_some() { 150 } else { 400 };
347 let sol = match derivatives {
348 PeqDerivatives::TwoPoint => {
349 let mut cache = ResidualCache::new(req.g, seed.bands.len());
350 optimize::least_squares(
351 |x| {
352 if cancelled() {
353 vec![0.0; req.g.len()]
354 } else {
355 cache.evaluate(req, x)
356 }
357 },
358 &seed.x0,
359 &seed.lb,
360 &seed.ub,
361 max_nfev,
362 )
363 }
364 PeqDerivatives::Analytic => {
365 let cache = RefCell::new(AnalyticResidualCache::new(req.g, seed.bands.len()));
366 optimize::least_squares_analytic(
367 |x| {
368 if cancelled() {
369 vec![0.0; req.g.len()]
370 } else {
371 cache.borrow_mut().evaluate(req, x)
372 }
373 },
374 |x| {
375 if cancelled() {
376 Mat::zeros(req.g.len(), x.len())
377 } else {
378 cache.borrow_mut().jacobian(req, x)
379 }
380 },
381 &seed.x0,
382 &seed.lb,
383 &seed.ub,
384 optimize::Options {
385 max_nfev,
386 ..optimize::Options::default()
387 },
388 None,
389 )
390 }
391 };
392 let Ok(sol) = sol else {
393 return result_bands(req, &seed.x0);
395 };
396 result_bands(req, &sol.x)
397}
398
399fn argmax(v: &[f64]) -> usize {
400 assert!(!v.is_empty());
401 (1..v.len()).fold(0, |k, i| if v[i] > v[k] { i } else { k })
402}
403fn geomspace(lo: f64, hi: f64, n: usize) -> Vec<f64> {
404 let start = lo.log10();
405 let step = (hi.log10() - start) / (n - 1) as f64;
406 let mut g: Vec<_> = (0..n)
407 .map(|i| 10_f64.powf(i as f64 * step + start))
408 .collect();
409 g[0] = lo;
410 g[n - 1] = hi;
411 g
412}
413fn interp(x: f64, f: &[f64], y: &[f64]) -> f64 {
414 let i = f.partition_point(|v| *v <= x).saturating_sub(1);
415 if x <= f[0] {
416 y[0]
417 } else if i + 1 == f.len() || x == f[i] {
418 y[i]
419 } else {
420 (y[i + 1] - y[i]) / (f[i + 1] - f[i]) * (x - f[i]) + y[i]
421 }
422}
423fn median(v: &[f64]) -> f64 {
424 let mut v = v.to_vec();
425 v.sort_by(f64::total_cmp);
426 let k = v.len() / 2;
427 if v.len().is_multiple_of(2) {
428 (v[k - 1] + v[k]) / 2.0
429 } else {
430 v[k]
431 }
432}
433fn sum(v: &[f64]) -> f64 {
435 if v.len() < 8 {
436 return v.iter().fold(-0.0, |s, x| s + x);
437 }
438 if v.len() > 128 {
439 let m = (v.len() / 2) / 8 * 8;
440 return sum(&v[..m]) + sum(&v[m..]);
441 }
442 let mut r: [f64; 8] = v[..8].try_into().unwrap();
443 let end = v.len() / 8 * 8;
444 for c in v[8..end].as_chunks::<8>().0 {
445 for (s, x) in r.iter_mut().zip(c) {
446 *s += x;
447 }
448 }
449 let s = ((r[0] + r[1]) + (r[2] + r[3])) + ((r[4] + r[5]) + (r[6] + r[7]));
450 v[end..].iter().fold(s, |s, x| s + x)
451}
452
453pub fn fit_gd_cuts(f: &[f64], h: &[C], lo: f64, hi: f64, n_max: usize, depth_db: f64) -> Vec<Peq> {
454 fit_gd_cuts_until(f, h, lo, hi, n_max, depth_db, &|| false)
455}
456
457pub fn fit_gd_cuts_until(
463 f: &[f64],
464 h: &[C],
465 lo: f64,
466 hi: f64,
467 n_max: usize,
468 depth_db: f64,
469 cancelled: &dyn Fn() -> bool,
470) -> Vec<Peq> {
471 let g = geomspace(lo, hi, 1500);
472 gd_cuts_on_grid(f, h, &g, None, n_max, depth_db, cancelled)
473}
474
475pub(crate) fn fit_gd_cuts_bands_until(
476 f: &[f64],
477 h: &[C],
478 bands: &[(f64, f64)],
479 n_max: usize,
480 depth_db: f64,
481 cancelled: &dyn Fn() -> bool,
482) -> Vec<Peq> {
483 let g = crate::rc::band_grid(bands, 20.0, 1500);
484 gd_cuts_on_grid(f, h, &g, Some(bands), n_max, depth_db, cancelled)
485}
486
487#[allow(clippy::too_many_arguments)]
488fn gd_cuts_on_grid(
489 f: &[f64],
490 h: &[C],
491 g: &[f64],
492 bands: Option<&[(f64, f64)]>,
493 n_max: usize,
494 depth_db: f64,
495 cancelled: &dyn Fn() -> bool,
496) -> Vec<Peq> {
497 let mut cur = h.to_vec();
498 let mut cuts = Vec::new();
499 if bands.is_some() && g.len() < 2 {
501 return cuts;
502 }
503 for _ in 0..n_max {
504 if cancelled() {
505 break;
506 }
507 let gd = response::gd_curve(&cur, f, g, Fraction::Scalar(24.0)).expect("valid GD grid");
508 let base: Vec<_> = (0..g.len())
509 .map(|i| median(&gd[i.saturating_sub(40)..(i + 40).min(g.len())]))
510 .collect();
511 let mut exc: Vec<_> = gd.iter().zip(&base).map(|(g, b)| g - b).collect();
512 let (k, centre) = loop {
515 let k = argmax(&exc);
516 if exc[k] < 10.0 {
517 break (k, None);
518 }
519 match cut_centre_bounds(g[k], bands) {
520 Some(bounds) => break (k, Some(bounds)),
521 None => exc[k] = f64::NEG_INFINITY,
522 }
523 };
524 let Some((centre_lb, centre_ub)) = centre else {
525 break;
526 };
527 let f0 = g[k];
528 let width = 2_f64.powf(1.0 / 3.0);
529 let gw: Vec<_> = g
530 .iter()
531 .copied()
532 .filter(|&g| g >= f0 / width && g <= f0 * width)
533 .collect();
534 let magnitude: Vec<_> = cur.iter().map(|&h| response::magnitude(h)).collect();
535 let mag0: Vec<_> = gw
536 .iter()
537 .map(|&g| 20.0 * (interp(g, f, &magnitude).abs() + 1e-12).log10())
538 .collect();
539 let res = |x: &[f64]| {
540 let hp: Vec<_> = cur
541 .iter()
542 .zip(response::peaking_response(
543 f,
544 x[0].exp(),
545 x[1].exp(),
546 x[2],
547 SR_F,
548 ))
549 .map(|(h, p)| h * p)
550 .collect();
551 let gd2 =
552 response::gd_curve(&hp, f, &gw, Fraction::Scalar(24.0)).expect("valid GD grid");
553 let mag: Vec<_> = hp.iter().map(|&h| response::magnitude(h)).collect();
554 gd2.iter()
555 .zip(&gw)
556 .map(|(gd, &w)| (gd - interp(w, g, &base)) / 5.0)
557 .chain(
558 gw.iter()
559 .zip(&mag0)
560 .map(|(&w, m)| 20.0 * (interp(w, f, &mag).abs() + 1e-12).log10() - m),
561 )
562 .collect::<Vec<_>>()
563 };
564 let x0 = [f0.ln(), 8_f64.ln(), -1.0];
565 let lb = [centre_lb, 2_f64.ln(), -depth_db];
566 let ub = [centre_ub, 40_f64.ln(), 0.0];
567 let sol = optimize::least_squares(
568 |x| {
569 if cancelled() {
570 vec![0.0; 2 * gw.len()]
571 } else {
572 res(x)
573 }
574 },
575 &x0,
576 &lb,
577 &ub,
578 60,
579 )
580 .expect("valid GD fit");
581 if cancelled() {
582 break;
583 }
584 let band = (sol.x[0].exp(), sol.x[1].exp(), sol.x[2]);
585 let norm = |x: &[f64]| sum(&res(x).iter().map(|v| v * v).collect::<Vec<_>>());
586 if norm(&sol.x) >= norm(&[f0.ln(), 8_f64.ln(), 0.0]) * 0.95 || band.2 > -0.1 {
587 break;
588 }
589 cuts.push(band);
590 for (h, p) in cur
591 .iter_mut()
592 .zip(response::peaking_response(f, band.0, band.1, band.2, SR_F))
593 {
594 *h *= p;
595 }
596 }
597 cuts
598}
599
600fn cut_centre_bounds(f0: f64, bands: Option<&[(f64, f64)]>) -> Option<(f64, f64)> {
605 let (lb, ub) = ((f0 / 1.1).ln(), (f0 * 1.1).ln());
606 let Some(bands) = bands else {
607 return Some((lb, ub));
608 };
609 let &(lo, hi) = bands.iter().find(|&&(lo, hi)| f0 >= lo && f0 <= hi)?;
610 let (lb, ub) = (lb.max(lo.ln()), ub.min(hi.ln()));
611 (lb < ub).then_some((lb, ub))
612}
613
614#[allow(clippy::too_many_arguments)]
617pub fn xo_target_peq(
618 by_pos: &[(f64, &[C])],
619 target: &TargetCurve,
620 level_db: f64,
621 lo: f64,
622 hi: f64,
623 bands: usize,
624 boost: f64,
625 f_max: Option<f64>,
626 fitter: &mut Fitter<'_>,
627) -> Vec<Peq> {
628 let f = design_freqs();
629 let mut power = vec![0.0; f.len()];
630 let mut weight = 0.0;
631 for &(w, h) in by_pos {
632 assert_eq!(h.len(), f.len());
633 weight += w;
634 for (p, &h) in power.iter_mut().zip(h) {
635 let m = response::magnitude(h);
636 *p += w * (m * m);
637 }
638 }
639 for p in &mut power {
640 *p /= weight;
641 }
642 let frac = secs_dsp::grid::frac_for(f);
643 let sm = smooth::smooth_power(f, &power, Fraction::PerBin(&frac)).expect("design grid");
644 let sm3 = smooth::smooth_power(f, &power, Fraction::Scalar(3.0)).expect("design grid");
645 let tgt = target.sample(&f.iter().map(|v| v.max(1.0)).collect::<Vec<_>>());
646 let corr: Vec<_> = sm
647 .iter()
648 .zip(sm3)
649 .zip(tgt)
650 .map(|((&s, s3), t)| {
651 (t + level_db - (10.0 * (s + 1e-30).log10()).max(10.0 * (s3 + 1e-30).log10() - 3.0))
652 .clamp(-25.0, boost)
653 })
654 .collect();
655 let g = geomspace(15.0, 2000.0, 500);
656 let c: Vec<_> = g
657 .iter()
658 .map(|&g| {
659 let soft = ((g / (lo / 1.4)).log2() / 0.5).clamp(0.0, 1.0)
660 * (((hi * 1.4) / g).log2() / 0.5).clamp(0.0, 1.0);
661 interp(g, f, &corr) * soft
662 })
663 .collect();
664 fitter(&FitRequest {
665 g: &g,
666 corr_db: &c,
667 n: bands,
668 lo,
669 hi,
670 boost,
671 ring_ms: None,
672 f_max,
673 bands: None,
674 })
675}
676
677#[cfg(any(test, feature = "test-support"))]
678#[doc(hidden)]
679#[derive(Debug, Clone)]
680pub struct RecordedFit {
681 pub g: Vec<f64>,
682 pub corr_db: Vec<f64>,
683 pub n: usize,
684 pub lo: f64,
685 pub hi: f64,
686 pub boost: f64,
687 pub ring_ms: Option<f64>,
688 pub f_max: Option<f64>,
689 pub result: Vec<Peq>,
690}
691#[cfg(any(test, feature = "test-support"))]
692impl RecordedFit {
693 pub fn request(&self) -> FitRequest<'_> {
694 FitRequest {
695 g: &self.g,
696 corr_db: &self.corr_db,
697 n: self.n,
698 lo: self.lo,
699 hi: self.hi,
700 boost: self.boost,
701 ring_ms: self.ring_ms,
702 f_max: self.f_max,
703 bands: None,
704 }
705 }
706}
707#[cfg(any(test, feature = "test-support"))]
708#[doc(hidden)]
709pub fn replay_fitter(
710 calls: Vec<RecordedFit>,
711 rtol: f64,
712) -> impl FnMut(&FitRequest<'_>) -> Vec<Peq> {
713 let mut next = 0;
714 move |req| {
715 let index = next;
716 let call = calls
717 .get(index)
718 .unwrap_or_else(|| panic!("fit call {index}: unexpected extra call"));
719 assert_eq!(req.n, call.n, "fit call {index}: n");
720 let mut worst = (0.0_f64, String::new());
721 for (name, a, b) in [
722 ("g", req.g, call.g.as_slice()),
723 ("corr_db", req.corr_db, call.corr_db.as_slice()),
724 (
725 "scalars",
726 &[req.lo, req.hi, req.boost],
727 &[call.lo, call.hi, call.boost],
728 ),
729 ] {
730 assert_eq!(a.len(), b.len(), "fit call {index}: {name} length");
731 for (i, (&a, &b)) in a.iter().zip(b).enumerate() {
732 let err = (a - b).abs() / b.abs().max(1.0);
733 if !err.is_finite() || err > worst.0 {
734 worst = (err, format!("{name}[{i}] got={a:e} expected={b:e}"));
735 }
736 }
737 }
738 for (name, a, b) in [
739 ("ring_ms", req.ring_ms, call.ring_ms),
740 ("f_max", req.f_max, call.f_max),
741 ] {
742 assert_eq!(
743 a.is_some(),
744 b.is_some(),
745 "fit call {index}: {name} presence"
746 );
747 if let (Some(a), Some(b)) = (a, b) {
748 let err = (a - b).abs() / b.abs().max(1.0);
749 if !err.is_finite() || err > worst.0 {
750 worst = (err, format!("{name} got={a:e} expected={b:e}"));
751 }
752 }
753 }
754 assert!(
755 worst.0.is_finite() && worst.0 <= rtol,
756 "fit call {index}: worst mismatch {} relative={} tolerance={rtol}",
757 worst.1,
758 worst.0
759 );
760 next += 1;
761 call.result.clone()
762 }
763}
764
765#[cfg(test)]
766mod analytic_tests {
767 use super::*;
768
769 #[test]
770 fn a_nonfinite_response_bin_weighs_as_a_large_finite_residual() {
771 let g = [1_000.0, 24_000.0];
772 let corr = [0.0, 0.0];
773 let req = FitRequest {
774 g: &g,
775 corr_db: &corr,
776 n: 1,
777 lo: 20.0,
778 hi: 20_000.0,
779 boost: 6.0,
780 ring_ms: None,
781 f_max: None,
782 bands: None,
783 };
784 let h = vec![C::new(1.0, 0.0), C::new(f64::NAN, f64::NAN)];
785 assert_eq!(
786 weighted_residual(&req, h),
787 [20.0 * (1.0_f64 + 1e-12).log10(), 1e12]
788 );
789 }
790
791 #[test]
796 fn fit_survives_a_band_whose_perturbed_response_is_not_finite() {
797 let Some(upper) = (1..=1_000_000)
798 .map(|multiple| 24_000.0 * f64::from(multiple))
799 .find(|&f| {
800 let h =
801 response::peaking_response(&[24_000.0], f.ln().exp(), 15.0, -6.0, 48_000.0)[0];
802 !h.re.is_finite() || !h.im.is_finite()
803 })
804 else {
805 eprintln!("skipped: no Nyquist multiple cancels to 0/0 with this libm");
806 return;
807 };
808 let lower = upper * (1.0 - 1e-9);
809 let g = [24_000.0, lower];
810 let corr = [0.0, -6.0];
811 let bands = [(lower, upper)];
812 let req = FitRequest {
813 g: &g,
814 corr_db: &corr,
815 n: 1,
816 lo: lower,
817 hi: upper,
818 boost: 6.0,
819 ring_ms: None,
820 f_max: Some(upper),
821 bands: Some(&bands),
822 };
823 let seed = seed(&req);
824 assert!(
825 residual(&req, &seed.x0)
826 .iter()
827 .all(|value| value.is_finite())
828 );
829 let mut probe = seed.x0.clone();
830 probe[0] = seed.ub[0];
831 let raw = response::peq_response(req.g, &result_bands(&req, &probe));
832 assert!(raw.iter().any(|h| !h.re.is_finite() || !h.im.is_finite()));
833
834 for result in [
835 fit_peq(&req),
836 fit_peq_with(&req, &|| false, PeqDerivatives::Analytic),
837 ] {
838 assert_eq!(result.len(), 1);
839 assert!(
840 result
841 .iter()
842 .all(|&(f, q, gain)| f.is_finite() && q.is_finite() && gain.is_finite())
843 );
844 }
845 }
846
847 #[test]
848 fn analytic_peq_jacobian_differentiates_the_weighted_residual() {
849 let g = geomspace(15.0, 3000.0, 100);
850 let corr = vec![0.0; g.len()];
851 let req = FitRequest {
852 g: &g,
853 corr_db: &corr,
854 n: 3,
855 lo: 20.0,
856 hi: 250.0,
857 boost: 6.0,
858 ring_ms: None,
859 f_max: None,
860 bands: None,
861 };
862 let x = [
863 30.0_f64.ln(),
864 1.0_f64.ln(),
865 -10.0,
866 80.0_f64.ln(),
867 4.0_f64.ln(),
868 3.0,
869 200.0_f64.ln(),
870 2.0_f64.ln(),
871 -5.0,
872 ];
873 let mut cache = AnalyticResidualCache::new(&g, 3);
874 let jac = cache.jacobian(&req, &x);
875 for j in 0..x.len() {
876 let samples: Vec<_> = [-2.0, -1.0, 1.0, 2.0]
877 .iter()
878 .map(|multiple| {
879 let mut probe = x;
880 probe[j] += multiple * 1e-3;
881 residual(&req, &probe)
882 })
883 .collect();
884 let error = (0..g.len())
885 .map(|i| {
886 let central = (samples[0][i] - 8.0 * samples[1][i] + 8.0 * samples[2][i]
887 - samples[3][i])
888 / 0.012;
889 (jac[(i, j)] - central).abs()
890 })
891 .fold(0.0_f64, f64::max);
892 let scale = (0..g.len())
893 .map(|i| jac[(i, j)].abs())
894 .fold(1.0_f64, f64::max);
895 assert!(error < 3e-6 * scale, "column {j}: {error}/{scale}");
896 }
897 let cached = cache.evaluate(&req, &x);
898 assert!(
899 cached
900 .iter()
901 .zip(residual(&req, &x))
902 .all(|(a, b)| a.to_bits() == b.to_bits())
903 );
904 }
905
906 #[test]
907 fn analytic_peq_jacobian_retains_the_additive_magnitude_floor() {
908 let g = [100.0];
909 let corr = [0.0];
910 let req = FitRequest {
911 g: &g,
912 corr_db: &corr,
913 n: 16,
914 lo: 20.0,
915 hi: 250.0,
916 boost: 6.0,
917 ring_ms: None,
918 f_max: None,
919 bands: None,
920 };
921 let x = [100.0_f64.ln(), 2.0_f64.ln(), -25.0].repeat(16);
922 let mut cache = AnalyticResidualCache::new(&g, 16);
923 let jac = cache.jacobian(&req, &x);
924 let magnitude = response::magnitude(cache.cascade[0]);
925 let expected = magnitude / (magnitude + 1e-12);
926 assert!(expected > 0.0 && expected < 1e-6);
927 assert!((jac[(0, 2)] / expected - 1.0).abs() < 1e-8);
928 }
929}