1use crate::constants::*;
7use crate::spectrum::{
8 bose_einstein, delta_n_over_n, delta_rho_over_rho, g_bb, mu_shape, planck, y_shape,
9};
10
11pub const DEFAULT_DECOMP_X_MIN: f64 = 0.5;
17pub const DEFAULT_DECOMP_X_MAX: f64 = 18.0;
18
19#[derive(Debug, Clone)]
21pub struct DistortionParams {
22 pub mu: f64,
24 pub y: f64,
26 pub delta_t_over_t: f64,
28 pub delta_rho_over_rho: f64,
30 pub delta_n_over_n: f64,
32 pub residual: Vec<f64>,
34}
35
36fn band_weights(x_grid: &[f64], x_min: f64, x_max: f64) -> (Vec<usize>, Vec<f64>) {
38 let n = x_grid.len();
39 let mut idx = Vec::new();
40 let mut w = Vec::new();
41 for i in 0..n {
42 if x_grid[i] < x_min || x_grid[i] > x_max {
43 continue;
44 }
45 let dx = if n == 1 {
46 1.0
47 } else if i == 0 {
48 x_grid[1] - x_grid[0]
49 } else if i == n - 1 {
50 x_grid[n - 1] - x_grid[n - 2]
51 } else {
52 0.5 * (x_grid[i + 1] - x_grid[i - 1])
53 };
54 idx.push(i);
55 w.push(dx);
56 }
57 (idx, w)
58}
59
60pub fn decompose_gram_schmidt(
83 x_grid: &[f64],
84 delta_n: &[f64],
85 x_min: f64,
86 x_max: f64,
87) -> DistortionParams {
88 assert_eq!(
89 x_grid.len(),
90 delta_n.len(),
91 "x_grid and delta_n length mismatch"
92 );
93 let n = x_grid.len();
94
95 let drho_over_rho_val = delta_rho_over_rho(x_grid, delta_n);
96 let dn_over_n_val = delta_n_over_n(x_grid, delta_n);
97
98 let (idx, w) = band_weights(x_grid, x_min, x_max);
99 let k = idx.len();
100
101 let degenerate_return = || DistortionParams {
102 mu: 0.0,
103 y: 0.0,
104 delta_t_over_t: 0.0,
105 delta_rho_over_rho: drho_over_rho_val,
106 delta_n_over_n: dn_over_n_val,
107 residual: delta_n.to_vec(),
108 };
109 if k < 3 {
110 return degenerate_return();
111 }
112
113 let m_vec: Vec<f64> = idx.iter().map(|&i| mu_shape(x_grid[i])).collect();
115 let y_vec: Vec<f64> = idx.iter().map(|&i| y_shape(x_grid[i])).collect();
116 let g_vec: Vec<f64> = idx.iter().map(|&i| g_bb(x_grid[i])).collect();
117 let dn_vec: Vec<f64> = idx.iter().map(|&i| delta_n[i]).collect();
118
119 let inner = |a: &[f64], b: &[f64]| -> f64 {
120 let mut s = 0.0;
121 for i in 0..k {
122 s += a[i] * b[i] * w[i];
123 }
124 s
125 };
126
127 let y_norm2 = inner(&y_vec, &y_vec);
128 if y_norm2 < 1e-100 {
129 return degenerate_return();
130 }
131 let y_norm = y_norm2.sqrt();
132 let e_y: Vec<f64> = y_vec.iter().map(|v| v / y_norm).collect();
133
134 let m_y = inner(&m_vec, &e_y);
135 let m_perp: Vec<f64> = m_vec
136 .iter()
137 .zip(e_y.iter())
138 .map(|(mi, ei)| mi - m_y * ei)
139 .collect();
140 let m_perp_norm2 = inner(&m_perp, &m_perp);
141 if m_perp_norm2 < 1e-100 {
142 return degenerate_return();
143 }
144 let m_perp_norm = m_perp_norm2.sqrt();
145 let e_mu: Vec<f64> = m_perp.iter().map(|v| v / m_perp_norm).collect();
146
147 let g_y = inner(&g_vec, &e_y);
148 let g_mu = inner(&g_vec, &e_mu);
149 let g_perp: Vec<f64> = g_vec
150 .iter()
151 .zip(e_y.iter())
152 .zip(e_mu.iter())
153 .map(|((gi, ey), em)| gi - g_y * ey - g_mu * em)
154 .collect();
155 let g_perp_norm2 = inner(&g_perp, &g_perp);
156 if g_perp_norm2 < 1e-100 {
157 return degenerate_return();
158 }
159 let g_perp_norm = g_perp_norm2.sqrt();
160
161 let a_y = inner(&dn_vec, &e_y);
163 let a_mu = inner(&dn_vec, &e_mu);
164 let a_t = {
165 let e_t: Vec<f64> = g_perp.iter().map(|v| v / g_perp_norm).collect();
166 inner(&dn_vec, &e_t)
167 };
168
169 let delta_t = a_t / g_perp_norm;
175 let mu = (a_mu - delta_t * g_mu) / m_perp_norm;
176 let y = (a_y - delta_t * g_y - mu * m_y) / y_norm;
177
178 let mut residual = vec![0.0; n];
179 for i in 0..n {
180 let xx = x_grid[i];
181 residual[i] = delta_n[i] - mu * mu_shape(xx) - y * y_shape(xx) - delta_t * g_bb(xx);
182 }
183
184 DistortionParams {
185 mu,
186 y,
187 delta_t_over_t: delta_t,
188 delta_rho_over_rho: drho_over_rho_val,
189 delta_n_over_n: dn_over_n_val,
190 residual,
191 }
192}
193
194pub fn decompose_nonlinear_be(
218 x_grid: &[f64],
219 delta_n: &[f64],
220 x_min: f64,
221 x_max: f64,
222) -> DistortionParams {
223 assert_eq!(
224 x_grid.len(),
225 delta_n.len(),
226 "x_grid and delta_n length mismatch"
227 );
228 let n = x_grid.len();
229
230 let drho_over_rho_val = delta_rho_over_rho(x_grid, delta_n);
231 let dn_over_n_val = delta_n_over_n(x_grid, delta_n);
232
233 let (idx, w) = band_weights(x_grid, x_min, x_max);
234 let k = idx.len();
235 if k < 3 {
236 return DistortionParams {
237 mu: 0.0,
238 y: 0.0,
239 delta_t_over_t: 0.0,
240 delta_rho_over_rho: drho_over_rho_val,
241 delta_n_over_n: dn_over_n_val,
242 residual: delta_n.to_vec(),
243 };
244 }
245
246 let model_at = |xi: f64, mu: f64, delta: f64, y_par: f64| -> f64 {
250 (bose_einstein(xi, mu) - planck(xi)) + delta * g_bb(xi) + y_par * y_shape(xi)
251 };
252 let chi2_at = |mu: f64, delta: f64, y_par: f64| -> f64 {
253 let mut s = 0.0;
254 for q in 0..k {
255 let xi = x_grid[idx[q]];
256 let r = delta_n[idx[q]] - model_at(xi, mu, delta, y_par);
257 s += r * r * w[q];
258 }
259 s
260 };
261
262 let gs = decompose_gram_schmidt(x_grid, delta_n, x_min, x_max);
264 let mut mu = gs.mu;
265 let mut delta = gs.delta_t_over_t + gs.mu / BETA_MU;
266 let mut y_par = gs.y;
267
268 const MAX_ITER: usize = 100;
269 const TOL: f64 = 1e-12;
270 let mut lambda = 1e-6_f64;
271 let mut prev_chi2 = chi2_at(mu, delta, y_par);
272
273 for _ in 0..MAX_ITER {
274 let mut ata = [[0.0_f64; 3]; 3];
275 let mut atr = [0.0_f64; 3];
276 for q in 0..k {
277 let xi = x_grid[idx[q]];
278 let wi = w[q];
279 let r = delta_n[idx[q]] - model_at(xi, mu, delta, y_par);
280
281 let xpm = xi + mu;
283 let d_mu_j = if xpm.abs() < 1e-6 {
284 -1.0 / (xpm * xpm)
285 } else {
286 let em = xpm.exp_m1();
287 -(1.0 + em) / (em * em)
288 };
289 let d_delta_j = g_bb(xi);
291 let d_y_j = y_shape(xi);
292
293 let jac = [d_mu_j, d_delta_j, d_y_j];
294 for a in 0..3 {
295 atr[a] += jac[a] * r * wi;
296 for b in 0..3 {
297 ata[a][b] += jac[a] * jac[b] * wi;
298 }
299 }
300 }
301
302 let mut accepted = false;
305 let mut step_mu = 0.0_f64;
306 let mut step_d = 0.0_f64;
307 let mut step_y = 0.0_f64;
308 for _ls in 0..20 {
309 let mut a = ata;
310 for i in 0..3 {
311 a[i][i] = ata[i][i] * (1.0 + lambda) + 1e-40;
312 }
313 let c00 = a[1][1] * a[2][2] - a[1][2] * a[2][1];
314 let c01 = -(a[1][0] * a[2][2] - a[1][2] * a[2][0]);
315 let c02 = a[1][0] * a[2][1] - a[1][1] * a[2][0];
316 let det = a[0][0] * c00 + a[0][1] * c01 + a[0][2] * c02;
317 if det.abs() < 1e-50 {
318 lambda *= 10.0;
319 continue;
320 }
321 let c10 = -(a[0][1] * a[2][2] - a[0][2] * a[2][1]);
322 let c11 = a[0][0] * a[2][2] - a[0][2] * a[2][0];
323 let c12 = -(a[0][0] * a[2][1] - a[0][1] * a[2][0]);
324 let c20 = a[0][1] * a[1][2] - a[0][2] * a[1][1];
325 let c21 = -(a[0][0] * a[1][2] - a[0][2] * a[1][0]);
326 let c22 = a[0][0] * a[1][1] - a[0][1] * a[1][0];
327
328 step_mu = (c00 * atr[0] + c10 * atr[1] + c20 * atr[2]) / det;
329 step_d = (c01 * atr[0] + c11 * atr[1] + c21 * atr[2]) / det;
330 step_y = (c02 * atr[0] + c12 * atr[1] + c22 * atr[2]) / det;
331
332 let mu_new = mu + step_mu;
333 let delta_new = delta + step_d;
334 let y_new = y_par + step_y;
335 let chi2_new = chi2_at(mu_new, delta_new, y_new);
336 if chi2_new < prev_chi2 {
337 mu = mu_new;
338 delta = delta_new;
339 y_par = y_new;
340 prev_chi2 = chi2_new;
341 lambda = (lambda * 0.5).max(1e-10);
342 accepted = true;
343 break;
344 }
345 lambda *= 2.0;
346 }
347 if !accepted {
348 break;
349 }
350 let step = step_mu.abs().max(step_d.abs()).max(step_y.abs());
351 let scale = mu.abs().max(delta.abs()).max(y_par.abs()).max(1.0);
352 if step < TOL * scale {
353 break;
354 }
355 }
356
357 let mut residual = vec![0.0; n];
358 for i in 0..n {
359 residual[i] = delta_n[i] - model_at(x_grid[i], mu, delta, y_par);
360 }
361
362 DistortionParams {
363 mu,
364 y: y_par,
365 delta_t_over_t: delta,
366 delta_rho_over_rho: drho_over_rho_val,
367 delta_n_over_n: dn_over_n_val,
368 residual,
369 }
370}
371
372pub fn decompose_distortion(x_grid: &[f64], delta_n: &[f64]) -> DistortionParams {
388 decompose_nonlinear_be(x_grid, delta_n, DEFAULT_DECOMP_X_MIN, DEFAULT_DECOMP_X_MAX)
389}
390
391pub fn decompose(x_grid: &[f64], delta_n: &[f64]) -> (f64, f64, f64) {
393 let params = decompose_distortion(x_grid, delta_n);
394 (params.mu, params.y, params.delta_t_over_t)
395}
396
397pub fn decomposition_band_count(x_grid: &[f64]) -> usize {
406 let (idx, _w) = band_weights(x_grid, DEFAULT_DECOMP_X_MIN, DEFAULT_DECOMP_X_MAX);
407 idx.len()
408}
409
410pub const FIRAS_MU_LIMIT: f64 = 9.0e-5;
414pub const FIRAS_Y_LIMIT: f64 = 1.5e-5;
415
416pub fn firas_check(params: &DistortionParams) -> (f64, f64) {
419 (
420 params.mu.abs() / FIRAS_MU_LIMIT,
421 params.y.abs() / FIRAS_Y_LIMIT,
422 )
423}
424
425pub fn delta_n_to_intensity_mjy(x: f64, delta_n: f64, t_cmb: f64) -> f64 {
431 let nu = x * crate::constants::K_BOLTZMANN * t_cmb / crate::constants::HPLANCK;
432 let prefactor = 2.0 * crate::constants::HPLANCK * nu.powi(3)
433 / (crate::constants::C_LIGHT * crate::constants::C_LIGHT);
434 prefactor * delta_n * 1e20
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 #[test]
443 fn test_decompose_pure_mu() {
444 let n = 5000;
447 let x_min = 0.01_f64;
448 let x_max = 30.0_f64;
449 let x_grid: Vec<f64> = (0..n)
450 .map(|i| x_min + (x_max - x_min) * i as f64 / (n - 1) as f64)
451 .collect();
452 let mu_true = 1e-5;
453 let delta_n: Vec<f64> = x_grid.iter().map(|&x| mu_true * mu_shape(x)).collect();
454
455 let params = decompose_distortion(&x_grid, &delta_n);
456 let rel_err = (params.mu - mu_true).abs() / mu_true;
457 assert!(
458 rel_err < 0.01,
459 "Extracted μ = {:.3e}, true = {:.3e}, rel_err = {rel_err}",
460 params.mu,
461 mu_true
462 );
463 assert!(
465 params.y.abs() < 0.01 * mu_true,
466 "Pure μ input produced spurious y = {:.3e} (μ = {:.3e})",
467 params.y,
468 mu_true
469 );
470 }
471
472 #[test]
473 fn test_decompose_pure_y() {
474 let n = 5000;
475 let x_min = 0.01_f64;
476 let x_max = 30.0_f64;
477 let x_grid: Vec<f64> = (0..n)
478 .map(|i| x_min + (x_max - x_min) * i as f64 / (n - 1) as f64)
479 .collect();
480 let y_true = 1e-6;
481 let delta_n: Vec<f64> = x_grid.iter().map(|&x| y_true * y_shape(x)).collect();
482
483 let params = decompose_distortion(&x_grid, &delta_n);
484 let rel_err = (params.y - y_true).abs() / y_true;
485 assert!(
486 rel_err < 0.01,
487 "Extracted y = {:.3e}, true = {:.3e}, rel_err = {rel_err}",
488 params.y,
489 y_true
490 );
491 assert!(
493 params.mu.abs() < 0.01 * y_true,
494 "Pure y input produced spurious μ = {:.3e} (y = {:.3e})",
495 params.mu,
496 y_true
497 );
498 }
499
500 #[test]
501 fn test_decompose_mixed_mu_y() {
502 let n = 5000;
503 let x_min = 0.01_f64;
504 let x_max = 30.0_f64;
505 let x_grid: Vec<f64> = (0..n)
506 .map(|i| x_min + (x_max - x_min) * i as f64 / (n - 1) as f64)
507 .collect();
508
509 let mu_true = 5e-6;
510 let y_true = 2e-6;
511 let delta_n: Vec<f64> = x_grid
512 .iter()
513 .map(|&x| mu_true * mu_shape(x) + y_true * y_shape(x))
514 .collect();
515
516 let params = decompose_distortion(&x_grid, &delta_n);
517 let mu_err = (params.mu - mu_true).abs() / mu_true;
518 let y_err = (params.y - y_true).abs() / y_true;
519 assert!(mu_err < 0.01, "Mixed μ err: {mu_err:.4}");
520 assert!(y_err < 0.01, "Mixed y err: {y_err:.4}");
521 }
522
523 #[test]
524 fn test_decompose_pure_delta_t() {
525 let n = 5000;
533 let x_min = 0.01_f64;
534 let x_max = 30.0_f64;
535 let x_grid: Vec<f64> = (0..n)
536 .map(|i| x_min + (x_max - x_min) * i as f64 / (n - 1) as f64)
537 .collect();
538 let dt_true = 1e-6;
539 let delta_n: Vec<f64> = x_grid.iter().map(|&x| dt_true * g_bb(x)).collect();
540
541 let params = decompose_distortion(&x_grid, &delta_n);
542
543 let dt_err = (params.delta_t_over_t - dt_true).abs() / dt_true;
545 assert!(
546 dt_err < 0.01,
547 "Extracted ΔT/T = {:.3e}, true = {:.3e}, rel_err = {dt_err:.4}",
548 params.delta_t_over_t,
549 dt_true
550 );
551 assert!(
553 params.mu.abs() < 0.01 * dt_true,
554 "Pure ΔT/T produced spurious μ = {:.3e} (dt = {:.3e})",
555 params.mu,
556 dt_true
557 );
558 assert!(
559 params.y.abs() < 0.01 * dt_true,
560 "Pure ΔT/T produced spurious y = {:.3e} (dt = {:.3e})",
561 params.y,
562 dt_true
563 );
564 }
565
566 #[test]
572 fn test_firas_check_values() {
573 let params = DistortionParams {
574 mu: 4.5e-5, y: 7.5e-6, delta_t_over_t: 0.0,
577 delta_rho_over_rho: 0.0,
578 delta_n_over_n: 0.0,
579 residual: vec![],
580 };
581 let (mu_frac, y_frac) = firas_check(¶ms);
582 assert!((mu_frac - 0.5).abs() < 1e-10, "mu_frac={mu_frac}");
583 assert!((y_frac - 0.5).abs() < 1e-10, "y_frac={y_frac}");
584 }
585
586 #[test]
587 fn test_delta_n_to_intensity_mjy() {
588 let t_cmb = 2.726;
590 let delta_n = 1e-5;
591 let intensity = delta_n_to_intensity_mjy(1.0, delta_n, t_cmb);
592 assert!(
593 intensity > 0.0,
594 "Positive Δn should give positive intensity"
595 );
596 assert!(intensity.is_finite());
597
598 let intensity2 = delta_n_to_intensity_mjy(1.0, 2.0 * delta_n, t_cmb);
600 assert!((intensity2 / intensity - 2.0).abs() < 1e-10);
601
602 let neg = delta_n_to_intensity_mjy(1.0, -delta_n, t_cmb);
604 assert!(neg < 0.0);
605 }
606
607 fn log_grid(n: usize, x_min: f64, x_max: f64) -> Vec<f64> {
612 let lmin = x_min.ln();
613 let lmax = x_max.ln();
614 (0..n)
615 .map(|i| (lmin + (lmax - lmin) * i as f64 / (n - 1) as f64).exp())
616 .collect()
617 }
618
619 const X_LO: f64 = DEFAULT_DECOMP_X_MIN;
620 const X_HI: f64 = DEFAULT_DECOMP_X_MAX;
621
622 #[test]
623 fn test_gram_schmidt_pure_mu() {
624 let x_grid = log_grid(4000, 1e-3, 40.0);
625 let mu_true = 1e-5;
626 let dn: Vec<f64> = x_grid.iter().map(|&x| mu_true * mu_shape(x)).collect();
627 let p = decompose_gram_schmidt(&x_grid, &dn, X_LO, X_HI);
628 assert!(
629 (p.mu - mu_true).abs() / mu_true < 1e-4,
630 "μ: got {:.6e}, expected {:.6e}",
631 p.mu,
632 mu_true
633 );
634 assert!(p.y.abs() < 1e-10 * mu_true.abs());
635 assert!(
637 p.delta_t_over_t.abs() < 1e-8 * mu_true.abs() + 1e-15,
638 "ΔT/T = {:.3e}",
639 p.delta_t_over_t
640 );
641 }
642
643 #[test]
644 fn test_gram_schmidt_pure_y() {
645 let x_grid = log_grid(4000, 1e-3, 40.0);
646 let y_true = 1e-6;
647 let dn: Vec<f64> = x_grid.iter().map(|&x| y_true * y_shape(x)).collect();
648 let p = decompose_gram_schmidt(&x_grid, &dn, X_LO, X_HI);
649 assert!((p.y - y_true).abs() / y_true < 1e-4, "y: got {:.6e}", p.y);
650 assert!(p.mu.abs() < 1e-10 * y_true.abs());
651 assert!(p.delta_t_over_t.abs() < 1e-10 * y_true.abs());
652 }
653
654 #[test]
655 fn test_gram_schmidt_pure_delta_t() {
656 let x_grid = log_grid(4000, 1e-3, 40.0);
657 let dt_true = 1e-6;
658 let dn: Vec<f64> = x_grid.iter().map(|&x| dt_true * g_bb(x)).collect();
659 let p = decompose_gram_schmidt(&x_grid, &dn, X_LO, X_HI);
660 assert!(
661 (p.delta_t_over_t - dt_true).abs() / dt_true < 1e-4,
662 "ΔT/T: got {:.6e}",
663 p.delta_t_over_t
664 );
665 assert!(p.mu.abs() < 1e-10 * dt_true.abs());
666 assert!(p.y.abs() < 1e-10 * dt_true.abs());
667 }
668
669 #[test]
670 fn test_bf_vs_gs_pure_mu() {
671 let x_grid = log_grid(4000, 1e-3, 40.0);
674 let mu_true = 1e-5;
675 let dn: Vec<f64> = x_grid.iter().map(|&x| mu_true * mu_shape(x)).collect();
676 let gs = decompose_gram_schmidt(&x_grid, &dn, X_LO, X_HI);
677 let bf = decompose_nonlinear_be(&x_grid, &dn, X_LO, X_HI);
678 let rel_mu = (bf.mu - gs.mu).abs() / gs.mu.abs();
679 assert!(
680 rel_mu < 1e-4,
681 "μ mismatch: BF={:.6e}, GS={:.6e}, rel={:.2e}",
682 bf.mu,
683 gs.mu,
684 rel_mu
685 );
686 let y_tol = 10.0 * mu_true * mu_true;
689 assert!(
690 bf.y.abs() < y_tol && gs.y.abs() < y_tol,
691 "y should be ≲ O(μ²)={:.1e}: BF={:.3e}, GS={:.3e}",
692 y_tol,
693 bf.y,
694 gs.y
695 );
696 let predicted_offset = gs.mu / BETA_MU;
697 let actual_offset = bf.delta_t_over_t - gs.delta_t_over_t;
698 assert!(
699 (actual_offset - predicted_offset).abs() / predicted_offset.abs() < 1e-3,
700 "ΔT offset: predicted μ/β_μ = {:.6e}, observed = {:.6e}",
701 predicted_offset,
702 actual_offset
703 );
704 }
705
706 #[test]
707 fn test_bf_pure_bose_einstein() {
708 let x_grid = log_grid(4000, 1e-3, 40.0);
711 let mu_true = 2e-5;
712 let dn: Vec<f64> = x_grid
713 .iter()
714 .map(|&x| bose_einstein(x, mu_true) - planck(x))
715 .collect();
716 let bf = decompose_nonlinear_be(&x_grid, &dn, X_LO, X_HI);
717 assert!(
718 (bf.mu - mu_true).abs() / mu_true < 1e-3,
719 "B&F μ: got {:.6e}, expected {:.6e}",
720 bf.mu,
721 mu_true
722 );
723 assert!(
724 bf.delta_t_over_t.abs() < 1e-4 * mu_true.abs(),
725 "B&F ΔT/T = {:.3e} should be ≈ 0 for pure BE",
726 bf.delta_t_over_t
727 );
728 let gs = decompose_gram_schmidt(&x_grid, &dn, X_LO, X_HI);
731 let rel = (gs.mu - bf.mu).abs() / bf.mu.abs();
732 assert!(
733 rel < 1e-3,
734 "GS vs BF μ: {} vs {}, rel={}",
735 gs.mu,
736 bf.mu,
737 rel
738 );
739 let predicted = -bf.mu / BETA_MU;
740 let rel_dt = (gs.delta_t_over_t - predicted).abs() / predicted.abs();
741 assert!(
742 rel_dt < 1e-3,
743 "GS ΔT/T: got {:.6e}, predicted {:.6e}",
744 gs.delta_t_over_t,
745 predicted
746 );
747 }
748
749 #[test]
750 fn test_bf_vs_gs_greens_function_spectrum() {
751 let x_grid = log_grid(4000, 1e-3, 40.0);
754 let z_h = 5e4;
755 let drho = 1e-5;
756 let dn: Vec<f64> = x_grid
757 .iter()
758 .map(|&x| drho * crate::greens::greens_function(x, z_h))
759 .collect();
760 let gs = decompose_gram_schmidt(&x_grid, &dn, X_LO, X_HI);
761 let bf = decompose_nonlinear_be(&x_grid, &dn, X_LO, X_HI);
762
763 eprintln!(
764 "GF z_h={:.0e}: GS μ={:.3e} y={:.3e} dT={:.3e} | BF μ={:.3e} y={:.3e} dT={:.3e}",
765 z_h, gs.mu, gs.y, gs.delta_t_over_t, bf.mu, bf.y, bf.delta_t_over_t
766 );
767
768 let rel_mu = (bf.mu - gs.mu).abs() / gs.mu.abs();
769 let rel_y = (bf.y - gs.y).abs() / gs.y.abs();
770 assert!(rel_mu < 1e-4, "μ match on GF spectrum: rel={:.2e}", rel_mu);
771 assert!(rel_y < 1e-4, "y match on GF spectrum: rel={:.2e}", rel_y);
772 let predicted = gs.mu / BETA_MU;
774 let observed = bf.delta_t_over_t - gs.delta_t_over_t;
775 assert!(
776 (observed - predicted).abs() / predicted.abs() < 1e-3,
777 "ΔT offset: predicted {:.3e}, observed {:.3e}",
778 predicted,
779 observed
780 );
781 }
782
783 #[test]
784 fn test_bf_vs_gs_mixed() {
785 let x_grid = log_grid(4000, 1e-3, 40.0);
786 let mu_t = 3e-6;
787 let y_t = 1e-6;
788 let dt_t = 5e-7;
789 let dn: Vec<f64> = x_grid
790 .iter()
791 .map(|&x| mu_t * mu_shape(x) + y_t * y_shape(x) + dt_t * g_bb(x))
792 .collect();
793 let gs = decompose_gram_schmidt(&x_grid, &dn, X_LO, X_HI);
794 let bf = decompose_nonlinear_be(&x_grid, &dn, X_LO, X_HI);
795
796 assert!((gs.mu - mu_t).abs() / mu_t < 1e-4, "GS μ");
798 assert!((gs.y - y_t).abs() / y_t < 1e-4, "GS y");
799 assert!((bf.mu - mu_t).abs() / mu_t < 1e-3, "BF μ");
800 assert!((bf.y - y_t).abs() / y_t < 1e-3, "BF y");
801 let rel_mu = (bf.mu - gs.mu).abs() / gs.mu.abs();
802 let rel_y = (bf.y - gs.y).abs() / gs.y.abs();
803 assert!(rel_mu < 1e-4, "μ match: rel={:.2e}", rel_mu);
804 assert!(rel_y < 1e-4, "y match: rel={:.2e}", rel_y);
805
806 let predicted_offset = gs.mu / BETA_MU;
808 let observed_offset = bf.delta_t_over_t - gs.delta_t_over_t;
809 assert!(
810 (observed_offset - predicted_offset).abs() / predicted_offset.abs() < 1e-3,
811 "ΔT offset: predicted {:.3e}, observed {:.3e}",
812 predicted_offset,
813 observed_offset
814 );
815 }
816}