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 w * (20.0 * (response::magnitude(h) + 1e-12).log10() - req.corr_db[i])
171 })
172 .collect()
173}
174
175pub(crate) struct ResidualCache {
178 z: Vec<C>,
179 bands: Vec<Option<([u64; 3], Vec<C>)>>,
180}
181
182impl ResidualCache {
183 pub(crate) fn new(g: &[f64], bands: usize) -> Self {
184 Self {
185 z: g.iter()
186 .map(|&f| C::from_polar(1.0, (-2.0 * PI * f) * (1.0 / 48000.0)))
187 .collect(),
188 bands: vec![None; bands],
189 }
190 }
191
192 pub(crate) fn evaluate(&mut self, req: &FitRequest<'_>, x: &[f64]) -> Vec<f64> {
193 let mut h = vec![C::new(1.0, 0.0); self.z.len()];
194 for (cached, b) in self.bands.iter_mut().zip(x.as_chunks::<3>().0) {
195 let key = b.map(f64::to_bits);
196 if cached.as_ref().is_none_or(|(old, _)| *old != key) {
197 *cached = Some((
198 key,
199 response::peaking_response_z(&self.z, b[0].exp(), b[1].exp(), b[2], 48000.0),
200 ));
201 }
202 for (h, p) in h.iter_mut().zip(&cached.as_ref().unwrap().1) {
203 *h *= p;
204 }
205 }
206 weighted_residual(req, h)
207 }
208}
209
210struct AnalyticBand {
211 key: [u64; 3],
212 response: Vec<C>,
213 logarithmic_derivatives: Option<[Vec<C>; 3]>,
214}
215
216struct AnalyticResidualCache {
219 z: Vec<C>,
220 bands: Vec<Option<AnalyticBand>>,
221 key: Vec<u64>,
222 cascade: Vec<C>,
223}
224
225impl AnalyticResidualCache {
226 fn new(g: &[f64], bands: usize) -> Self {
227 Self {
228 z: g.iter()
229 .map(|&f| C::from_polar(1.0, (-2.0 * PI * f) * (1.0 / 48000.0)))
230 .collect(),
231 bands: (0..bands).map(|_| None).collect(),
232 key: Vec::new(),
233 cascade: Vec::new(),
234 }
235 }
236
237 fn response(&mut self, x: &[f64]) {
238 if self.key.len() == x.len()
239 && self.key.iter().zip(x).all(|(&a, &b)| a == b.to_bits())
240 && !self.cascade.is_empty()
241 {
242 return;
243 }
244 self.cascade.clear();
245 self.cascade.resize(self.z.len(), C::new(1.0, 0.0));
246 for (cached, b) in self.bands.iter_mut().zip(x.as_chunks::<3>().0) {
247 let key = b.map(f64::to_bits);
248 if cached.as_ref().is_none_or(|old| old.key != key) {
249 *cached = Some(AnalyticBand {
250 key,
251 response: response::peaking_response_z(
252 &self.z,
253 b[0].exp(),
254 b[1].exp(),
255 b[2],
256 48000.0,
257 ),
258 logarithmic_derivatives: None,
259 });
260 }
261 for (h, p) in self
262 .cascade
263 .iter_mut()
264 .zip(&cached.as_ref().unwrap().response)
265 {
266 *h *= p;
267 }
268 }
269 self.key.clear();
270 self.key.extend(x.iter().map(|v| v.to_bits()));
271 }
272
273 fn evaluate(&mut self, req: &FitRequest<'_>, x: &[f64]) -> Vec<f64> {
274 self.response(x);
275 weighted_residual(req, self.cascade.clone())
276 }
277
278 fn jacobian(&mut self, req: &FitRequest<'_>, x: &[f64]) -> Mat<f64> {
279 self.response(x);
280 let factors: Vec<_> = self
281 .cascade
282 .iter()
283 .enumerate()
284 .map(|(i, &h)| {
285 let weight = if req.in_range(req.g[i]) { 1.0 } else { 0.15 };
286 let magnitude = response::magnitude(h);
287 weight * (20.0 / 10_f64.ln()) * magnitude / (magnitude + 1e-12)
288 })
289 .collect();
290 let mut jac = Mat::zeros(req.g.len(), x.len());
291 for (k, (cached, b)) in self.bands.iter_mut().zip(x.as_chunks::<3>().0).enumerate() {
292 let cached = cached.as_mut().unwrap();
293 let derivatives = cached.logarithmic_derivatives.get_or_insert_with(|| {
294 response::peaking_log_derivatives_z(&self.z, b[0].exp(), b[1].exp(), b[2], 48000.0)
295 });
296 for c in 0..3 {
297 for i in 0..req.g.len() {
298 jac[(i, 3 * k + c)] = factors[i] * derivatives[c][i].re;
299 }
300 }
301 }
302 jac
303 }
304}
305
306pub(crate) fn result_bands(req: &FitRequest<'_>, x: &[f64]) -> Vec<Peq> {
307 x.as_chunks::<3>()
308 .0
309 .iter()
310 .map(|b| {
311 let (f, mut q, gain) = (b[0].exp(), b[1].exp(), b[2]);
312 if let Some(ms) = req.ring_ms {
313 q = q.min(ring_q_cap(f, gain, ms));
314 }
315 (f, q, gain)
316 })
317 .collect()
318}
319
320pub fn fit_peq(req: &FitRequest<'_>) -> Vec<Peq> {
323 fit_peq_until(req, &|| false)
324}
325
326pub fn fit_peq_until(req: &FitRequest<'_>, cancelled: &dyn Fn() -> bool) -> Vec<Peq> {
332 fit_peq_with(req, cancelled, PeqDerivatives::TwoPoint)
333}
334
335pub fn fit_peq_with(
337 req: &FitRequest<'_>,
338 cancelled: &dyn Fn() -> bool,
339 derivatives: PeqDerivatives,
340) -> Vec<Peq> {
341 let seed = seed(req);
342 if seed.bands.is_empty() {
343 return Vec::new();
344 }
345 let max_nfev = if req.ring_ms.is_some() { 150 } else { 400 };
346 let sol = match derivatives {
347 PeqDerivatives::TwoPoint => {
348 let mut cache = ResidualCache::new(req.g, seed.bands.len());
349 optimize::least_squares(
350 |x| {
351 if cancelled() {
352 vec![0.0; req.g.len()]
353 } else {
354 cache.evaluate(req, x)
355 }
356 },
357 &seed.x0,
358 &seed.lb,
359 &seed.ub,
360 max_nfev,
361 )
362 }
363 PeqDerivatives::Analytic => {
364 let cache = RefCell::new(AnalyticResidualCache::new(req.g, seed.bands.len()));
365 optimize::least_squares_analytic(
366 |x| {
367 if cancelled() {
368 vec![0.0; req.g.len()]
369 } else {
370 cache.borrow_mut().evaluate(req, x)
371 }
372 },
373 |x| {
374 if cancelled() {
375 Mat::zeros(req.g.len(), x.len())
376 } else {
377 cache.borrow_mut().jacobian(req, x)
378 }
379 },
380 &seed.x0,
381 &seed.lb,
382 &seed.ub,
383 optimize::Options {
384 max_nfev,
385 ..optimize::Options::default()
386 },
387 None,
388 )
389 }
390 }
391 .expect("valid PEQ fit");
392 result_bands(req, &sol.x)
393}
394
395fn argmax(v: &[f64]) -> usize {
396 assert!(!v.is_empty());
397 (1..v.len()).fold(0, |k, i| if v[i] > v[k] { i } else { k })
398}
399fn geomspace(lo: f64, hi: f64, n: usize) -> Vec<f64> {
400 let start = lo.log10();
401 let step = (hi.log10() - start) / (n - 1) as f64;
402 let mut g: Vec<_> = (0..n)
403 .map(|i| 10_f64.powf(i as f64 * step + start))
404 .collect();
405 g[0] = lo;
406 g[n - 1] = hi;
407 g
408}
409fn interp(x: f64, f: &[f64], y: &[f64]) -> f64 {
410 let i = f.partition_point(|v| *v <= x).saturating_sub(1);
411 if x <= f[0] {
412 y[0]
413 } else if i + 1 == f.len() || x == f[i] {
414 y[i]
415 } else {
416 (y[i + 1] - y[i]) / (f[i + 1] - f[i]) * (x - f[i]) + y[i]
417 }
418}
419fn median(v: &[f64]) -> f64 {
420 let mut v = v.to_vec();
421 v.sort_by(f64::total_cmp);
422 let k = v.len() / 2;
423 if v.len().is_multiple_of(2) {
424 (v[k - 1] + v[k]) / 2.0
425 } else {
426 v[k]
427 }
428}
429fn sum(v: &[f64]) -> f64 {
431 if v.len() < 8 {
432 return v.iter().fold(-0.0, |s, x| s + x);
433 }
434 if v.len() > 128 {
435 let m = (v.len() / 2) / 8 * 8;
436 return sum(&v[..m]) + sum(&v[m..]);
437 }
438 let mut r: [f64; 8] = v[..8].try_into().unwrap();
439 let end = v.len() / 8 * 8;
440 for c in v[8..end].as_chunks::<8>().0 {
441 for (s, x) in r.iter_mut().zip(c) {
442 *s += x;
443 }
444 }
445 let s = ((r[0] + r[1]) + (r[2] + r[3])) + ((r[4] + r[5]) + (r[6] + r[7]));
446 v[end..].iter().fold(s, |s, x| s + x)
447}
448
449pub fn fit_gd_cuts(f: &[f64], h: &[C], lo: f64, hi: f64, n_max: usize, depth_db: f64) -> Vec<Peq> {
450 fit_gd_cuts_until(f, h, lo, hi, n_max, depth_db, &|| false)
451}
452
453pub fn fit_gd_cuts_until(
459 f: &[f64],
460 h: &[C],
461 lo: f64,
462 hi: f64,
463 n_max: usize,
464 depth_db: f64,
465 cancelled: &dyn Fn() -> bool,
466) -> Vec<Peq> {
467 let g = geomspace(lo, hi, 1500);
468 gd_cuts_on_grid(f, h, &g, None, n_max, depth_db, cancelled)
469}
470
471pub(crate) fn fit_gd_cuts_bands_until(
472 f: &[f64],
473 h: &[C],
474 bands: &[(f64, f64)],
475 n_max: usize,
476 depth_db: f64,
477 cancelled: &dyn Fn() -> bool,
478) -> Vec<Peq> {
479 let g = crate::rc::band_grid(bands, 20.0, 1500);
480 gd_cuts_on_grid(f, h, &g, Some(bands), n_max, depth_db, cancelled)
481}
482
483#[allow(clippy::too_many_arguments)]
484fn gd_cuts_on_grid(
485 f: &[f64],
486 h: &[C],
487 g: &[f64],
488 bands: Option<&[(f64, f64)]>,
489 n_max: usize,
490 depth_db: f64,
491 cancelled: &dyn Fn() -> bool,
492) -> Vec<Peq> {
493 let mut cur = h.to_vec();
494 let mut cuts = Vec::new();
495 if bands.is_some() && g.len() < 2 {
497 return cuts;
498 }
499 for _ in 0..n_max {
500 if cancelled() {
501 break;
502 }
503 let gd = response::gd_curve(&cur, f, g, Fraction::Scalar(24.0)).expect("valid GD grid");
504 let base: Vec<_> = (0..g.len())
505 .map(|i| median(&gd[i.saturating_sub(40)..(i + 40).min(g.len())]))
506 .collect();
507 let mut exc: Vec<_> = gd.iter().zip(&base).map(|(g, b)| g - b).collect();
508 let (k, centre) = loop {
511 let k = argmax(&exc);
512 if exc[k] < 10.0 {
513 break (k, None);
514 }
515 match cut_centre_bounds(g[k], bands) {
516 Some(bounds) => break (k, Some(bounds)),
517 None => exc[k] = f64::NEG_INFINITY,
518 }
519 };
520 let Some((centre_lb, centre_ub)) = centre else {
521 break;
522 };
523 let f0 = g[k];
524 let width = 2_f64.powf(1.0 / 3.0);
525 let gw: Vec<_> = g
526 .iter()
527 .copied()
528 .filter(|&g| g >= f0 / width && g <= f0 * width)
529 .collect();
530 let magnitude: Vec<_> = cur.iter().map(|&h| response::magnitude(h)).collect();
531 let mag0: Vec<_> = gw
532 .iter()
533 .map(|&g| 20.0 * (interp(g, f, &magnitude).abs() + 1e-12).log10())
534 .collect();
535 let res = |x: &[f64]| {
536 let hp: Vec<_> = cur
537 .iter()
538 .zip(response::peaking_response(
539 f,
540 x[0].exp(),
541 x[1].exp(),
542 x[2],
543 SR_F,
544 ))
545 .map(|(h, p)| h * p)
546 .collect();
547 let gd2 =
548 response::gd_curve(&hp, f, &gw, Fraction::Scalar(24.0)).expect("valid GD grid");
549 let mag: Vec<_> = hp.iter().map(|&h| response::magnitude(h)).collect();
550 gd2.iter()
551 .zip(&gw)
552 .map(|(gd, &w)| (gd - interp(w, g, &base)) / 5.0)
553 .chain(
554 gw.iter()
555 .zip(&mag0)
556 .map(|(&w, m)| 20.0 * (interp(w, f, &mag).abs() + 1e-12).log10() - m),
557 )
558 .collect::<Vec<_>>()
559 };
560 let x0 = [f0.ln(), 8_f64.ln(), -1.0];
561 let lb = [centre_lb, 2_f64.ln(), -depth_db];
562 let ub = [centre_ub, 40_f64.ln(), 0.0];
563 let sol = optimize::least_squares(
564 |x| {
565 if cancelled() {
566 vec![0.0; 2 * gw.len()]
567 } else {
568 res(x)
569 }
570 },
571 &x0,
572 &lb,
573 &ub,
574 60,
575 )
576 .expect("valid GD fit");
577 if cancelled() {
578 break;
579 }
580 let band = (sol.x[0].exp(), sol.x[1].exp(), sol.x[2]);
581 let norm = |x: &[f64]| sum(&res(x).iter().map(|v| v * v).collect::<Vec<_>>());
582 if norm(&sol.x) >= norm(&[f0.ln(), 8_f64.ln(), 0.0]) * 0.95 || band.2 > -0.1 {
583 break;
584 }
585 cuts.push(band);
586 for (h, p) in cur
587 .iter_mut()
588 .zip(response::peaking_response(f, band.0, band.1, band.2, SR_F))
589 {
590 *h *= p;
591 }
592 }
593 cuts
594}
595
596fn cut_centre_bounds(f0: f64, bands: Option<&[(f64, f64)]>) -> Option<(f64, f64)> {
601 let (lb, ub) = ((f0 / 1.1).ln(), (f0 * 1.1).ln());
602 let Some(bands) = bands else {
603 return Some((lb, ub));
604 };
605 let &(lo, hi) = bands.iter().find(|&&(lo, hi)| f0 >= lo && f0 <= hi)?;
606 let (lb, ub) = (lb.max(lo.ln()), ub.min(hi.ln()));
607 (lb < ub).then_some((lb, ub))
608}
609
610#[allow(clippy::too_many_arguments)]
613pub fn xo_target_peq(
614 by_pos: &[(f64, &[C])],
615 target: &TargetCurve,
616 level_db: f64,
617 lo: f64,
618 hi: f64,
619 bands: usize,
620 boost: f64,
621 f_max: Option<f64>,
622 fitter: &mut Fitter<'_>,
623) -> Vec<Peq> {
624 let f = design_freqs();
625 let mut power = vec![0.0; f.len()];
626 let mut weight = 0.0;
627 for &(w, h) in by_pos {
628 assert_eq!(h.len(), f.len());
629 weight += w;
630 for (p, &h) in power.iter_mut().zip(h) {
631 let m = response::magnitude(h);
632 *p += w * (m * m);
633 }
634 }
635 for p in &mut power {
636 *p /= weight;
637 }
638 let frac = secs_dsp::grid::frac_for(f);
639 let sm = smooth::smooth_power(f, &power, Fraction::PerBin(&frac)).expect("design grid");
640 let sm3 = smooth::smooth_power(f, &power, Fraction::Scalar(3.0)).expect("design grid");
641 let tgt = target.sample(&f.iter().map(|v| v.max(1.0)).collect::<Vec<_>>());
642 let corr: Vec<_> = sm
643 .iter()
644 .zip(sm3)
645 .zip(tgt)
646 .map(|((&s, s3), t)| {
647 (t + level_db - (10.0 * (s + 1e-30).log10()).max(10.0 * (s3 + 1e-30).log10() - 3.0))
648 .clamp(-25.0, boost)
649 })
650 .collect();
651 let g = geomspace(15.0, 2000.0, 500);
652 let c: Vec<_> = g
653 .iter()
654 .map(|&g| {
655 let soft = ((g / (lo / 1.4)).log2() / 0.5).clamp(0.0, 1.0)
656 * (((hi * 1.4) / g).log2() / 0.5).clamp(0.0, 1.0);
657 interp(g, f, &corr) * soft
658 })
659 .collect();
660 fitter(&FitRequest {
661 g: &g,
662 corr_db: &c,
663 n: bands,
664 lo,
665 hi,
666 boost,
667 ring_ms: None,
668 f_max,
669 bands: None,
670 })
671}
672
673#[cfg(any(test, feature = "test-support"))]
674#[doc(hidden)]
675#[derive(Debug, Clone)]
676pub struct RecordedFit {
677 pub g: Vec<f64>,
678 pub corr_db: Vec<f64>,
679 pub n: usize,
680 pub lo: f64,
681 pub hi: f64,
682 pub boost: f64,
683 pub ring_ms: Option<f64>,
684 pub f_max: Option<f64>,
685 pub result: Vec<Peq>,
686}
687#[cfg(any(test, feature = "test-support"))]
688impl RecordedFit {
689 pub fn request(&self) -> FitRequest<'_> {
690 FitRequest {
691 g: &self.g,
692 corr_db: &self.corr_db,
693 n: self.n,
694 lo: self.lo,
695 hi: self.hi,
696 boost: self.boost,
697 ring_ms: self.ring_ms,
698 f_max: self.f_max,
699 bands: None,
700 }
701 }
702}
703#[cfg(any(test, feature = "test-support"))]
704#[doc(hidden)]
705pub fn replay_fitter(
706 calls: Vec<RecordedFit>,
707 rtol: f64,
708) -> impl FnMut(&FitRequest<'_>) -> Vec<Peq> {
709 let mut next = 0;
710 move |req| {
711 let index = next;
712 let call = calls
713 .get(index)
714 .unwrap_or_else(|| panic!("fit call {index}: unexpected extra call"));
715 assert_eq!(req.n, call.n, "fit call {index}: n");
716 let mut worst = (0.0_f64, String::new());
717 for (name, a, b) in [
718 ("g", req.g, call.g.as_slice()),
719 ("corr_db", req.corr_db, call.corr_db.as_slice()),
720 (
721 "scalars",
722 &[req.lo, req.hi, req.boost],
723 &[call.lo, call.hi, call.boost],
724 ),
725 ] {
726 assert_eq!(a.len(), b.len(), "fit call {index}: {name} length");
727 for (i, (&a, &b)) in a.iter().zip(b).enumerate() {
728 let err = (a - b).abs() / b.abs().max(1.0);
729 if !err.is_finite() || err > worst.0 {
730 worst = (err, format!("{name}[{i}] got={a:e} expected={b:e}"));
731 }
732 }
733 }
734 for (name, a, b) in [
735 ("ring_ms", req.ring_ms, call.ring_ms),
736 ("f_max", req.f_max, call.f_max),
737 ] {
738 assert_eq!(
739 a.is_some(),
740 b.is_some(),
741 "fit call {index}: {name} presence"
742 );
743 if let (Some(a), Some(b)) = (a, b) {
744 let err = (a - b).abs() / b.abs().max(1.0);
745 if !err.is_finite() || err > worst.0 {
746 worst = (err, format!("{name} got={a:e} expected={b:e}"));
747 }
748 }
749 }
750 assert!(
751 worst.0.is_finite() && worst.0 <= rtol,
752 "fit call {index}: worst mismatch {} relative={} tolerance={rtol}",
753 worst.1,
754 worst.0
755 );
756 next += 1;
757 call.result.clone()
758 }
759}
760
761#[cfg(test)]
762mod analytic_tests {
763 use super::*;
764
765 #[test]
766 fn analytic_peq_jacobian_differentiates_the_weighted_residual() {
767 let g = geomspace(15.0, 3000.0, 100);
768 let corr = vec![0.0; g.len()];
769 let req = FitRequest {
770 g: &g,
771 corr_db: &corr,
772 n: 3,
773 lo: 20.0,
774 hi: 250.0,
775 boost: 6.0,
776 ring_ms: None,
777 f_max: None,
778 bands: None,
779 };
780 let x = [
781 30.0_f64.ln(),
782 1.0_f64.ln(),
783 -10.0,
784 80.0_f64.ln(),
785 4.0_f64.ln(),
786 3.0,
787 200.0_f64.ln(),
788 2.0_f64.ln(),
789 -5.0,
790 ];
791 let mut cache = AnalyticResidualCache::new(&g, 3);
792 let jac = cache.jacobian(&req, &x);
793 for j in 0..x.len() {
794 let samples: Vec<_> = [-2.0, -1.0, 1.0, 2.0]
795 .iter()
796 .map(|multiple| {
797 let mut probe = x;
798 probe[j] += multiple * 1e-3;
799 residual(&req, &probe)
800 })
801 .collect();
802 let error = (0..g.len())
803 .map(|i| {
804 let central = (samples[0][i] - 8.0 * samples[1][i] + 8.0 * samples[2][i]
805 - samples[3][i])
806 / 0.012;
807 (jac[(i, j)] - central).abs()
808 })
809 .fold(0.0_f64, f64::max);
810 let scale = (0..g.len())
811 .map(|i| jac[(i, j)].abs())
812 .fold(1.0_f64, f64::max);
813 assert!(error < 3e-6 * scale, "column {j}: {error}/{scale}");
814 }
815 let cached = cache.evaluate(&req, &x);
816 assert!(
817 cached
818 .iter()
819 .zip(residual(&req, &x))
820 .all(|(a, b)| a.to_bits() == b.to_bits())
821 );
822 }
823
824 #[test]
825 fn analytic_peq_jacobian_retains_the_additive_magnitude_floor() {
826 let g = [100.0];
827 let corr = [0.0];
828 let req = FitRequest {
829 g: &g,
830 corr_db: &corr,
831 n: 16,
832 lo: 20.0,
833 hi: 250.0,
834 boost: 6.0,
835 ring_ms: None,
836 f_max: None,
837 bands: None,
838 };
839 let x = [100.0_f64.ln(), 2.0_f64.ln(), -25.0].repeat(16);
840 let mut cache = AnalyticResidualCache::new(&g, 16);
841 let jac = cache.jacobian(&req, &x);
842 let magnitude = response::magnitude(cache.cascade[0]);
843 let expected = magnitude / (magnitude + 1e-12);
844 assert!(expected > 0.0 && expected < 1e-6);
845 assert!((jac[(0, 2)] / expected - 1.0).abs() < 1e-8);
846 }
847}