spectroxide/
distortion.rs

1//! Distortion extraction and characterization.
2//!
3//! Given the photon distortion Δn(x), extract the standard distortion
4//! parameters (μ, y, temperature shift) and compute residuals.
5
6use 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
11/// Default frequency band for Gram-Schmidt and B&F decompositions.
12///
13/// PIXIE-like window in dimensionless frequency at T₀ = 2.725 K:
14/// x ∈ [0.5, 18] corresponds to ν ∈ [28, 1020] GHz. Matches the
15/// experimental band used in CJ2014 Appendix A.
16pub const DEFAULT_DECOMP_X_MIN: f64 = 0.5;
17pub const DEFAULT_DECOMP_X_MAX: f64 = 18.0;
18
19/// Complete distortion decomposition result.
20#[derive(Debug, Clone)]
21pub struct DistortionParams {
22    /// Chemical potential μ
23    pub mu: f64,
24    /// Compton y-parameter
25    pub y: f64,
26    /// Temperature shift ΔT/T
27    pub delta_t_over_t: f64,
28    /// Fractional energy: Δρ/ρ
29    pub delta_rho_over_rho: f64,
30    /// Fractional photon number change: ΔN/N
31    pub delta_n_over_n: f64,
32    /// Residual distortion (not captured by μ, y, T)
33    pub residual: Vec<f64>,
34}
35
36/// Collect trapezoidal weights and indices for grid points within [x_min, x_max].
37fn 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
60/// CJ2014 Appendix A: Gram-Schmidt decomposition over a frequency band.
61///
62/// Reference: Chluba & Jeong (2014), arXiv:1306.5751, Appendix A.
63///
64/// Constructs an orthonormal basis (e_y, e_μ, e_T) for the three-dimensional
65/// subspace spanned by (Y_SZ, M, G) via Gram-Schmidt in the order
66///   1. e_y  = Y_SZ / |Y_SZ|
67///   2. e_μ  = M⊥  / |M⊥|,   with M⊥  = M  − (M·e_y) e_y
68///   3. e_T  = G⊥  / |G⊥|,   with G⊥  = G  − (G·e_y) e_y − (G·e_μ) e_μ
69/// under the inner product ⟨a, b⟩ = ∫_{x_min}^{x_max} a(x) b(x) dx
70/// (trapezoidal rule on the supplied grid). This generalises CJ2014's
71/// uniform-channel flat sum to our non-uniform x-grid and reduces to it in
72/// the continuum limit.
73///
74/// After projection, the coefficients (a_y, a_μ, a_T) = (⟨Δn, e_y⟩, ⟨Δn, e_μ⟩,
75/// ⟨Δn, e_T⟩) are mapped back to (μ, y, ΔT/T) via exact back-substitution of
76///   Δn ≈ μ M + y Y_SZ + (ΔT/T) G,
77/// giving
78///   ΔT/T = a_T / |G⊥|
79///   μ    = (a_μ − ΔT/T · G_μ)           / |M⊥|
80///   y    = (a_y − ΔT/T · G_y − μ · M_y) / |Y_SZ|
81/// with M_y = M·e_y·|Y_SZ|, G_y = G·e_y·|Y_SZ|, G_μ = G·e_μ·|M⊥|.
82pub 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    // Pre-evaluate shape vectors and distortion on the band.
114    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    // Projections of Δn onto the orthonormal basis.
162    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    // Back-substitute. Using M = m_y·e_y + |M⊥|·e_μ and
170    // G = g_y·e_y + g_μ·e_μ + |G⊥|·e_T in Δn = μ M + y Y_SZ + (ΔT/T) G:
171    //   a_y = (ΔT/T)·g_y + μ·m_y + y·|Y_SZ|
172    //   a_μ = (ΔT/T)·g_μ + μ·|M⊥|
173    //   a_T = (ΔT/T)·|G⊥|
174    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
194/// Bianchini & Fabbian (2022) nonlinear fit: μ inside the BE exponential.
195///
196/// Reference: Bianchini & Fabbian (2022), arXiv:2206.02762, Eqs. (1)–(4).
197///
198/// Model:
199///   Δn_model(x; μ, δ, y) = [n_pl(x/(1+δ)) − n_pl(x)]
200///                        + [n_BE(x+μ)    − n_pl(x)]
201///                        + y · Y_SZ(x)
202/// with δ ≡ ΔT/T₀. Fits (μ, δ, y) by Levenberg-Marquardt on the band
203/// [x_min, x_max] with a trapezoidal inner product matching
204/// `decompose_gram_schmidt`.
205///
206/// Initial guess: bootstrap from `decompose_gram_schmidt` (converted via
207/// δ_BF = δ_GS + μ/β_μ). This gives the linearised optimum for free; the
208/// LM iterations only refine the O(μ²) nonlinear correction.
209///
210/// In the small-(μ, δ, y) limit the model reduces to a linear fit of
211///   Δn ≈ δ·G(x) + μ·(−G(x)/x) + y·Y_SZ(x),
212/// which spans the same 3-D subspace as the CJ2014 basis (Y_SZ, M, G) since
213/// M(x) = G(x)/β_μ − G(x)/x. The two methods therefore give the SAME μ and y
214/// to O(μ²), but a DIFFERENT ΔT/T: a pure B&F BE distortion with chemical
215/// potential μ_BF has ΔT/T = 0 in the B&F parameterisation and ΔT/T = −μ_BF/β_μ
216/// in CJ2014. Concretely: δ_BF = δ_CJ + μ/β_μ.
217pub 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    // B&F 2022 model: nonlinear in μ (BE chemical potential inside the
247    // exponential) but linear in δ ≡ ΔT/T_0 (Taylor expansion of the
248    // blackbody to first order in ΔT, as in their Eq. 1).
249    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    // Bootstrap from GS (linearised answer, translated to B&F parameterisation).
263    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            // ∂ model / ∂μ  = d n_BE(x+μ)/dμ = − e^{x+μ}/(e^{x+μ}−1)²
282            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            // ∂ model / ∂δ = G_bb(x)  (linear temperature-shift term)
290            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        // LM step with backtracking: grow λ until step reduces χ², shrink on
303        // acceptance. Caps after 20 tries to avoid infinite loops.
304        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
372/// Decompose a spectral distortion into μ, y, and temperature shift components.
373///
374/// Default method: Bianchini & Fabbian (2022) nonlinear fit on the band
375/// [`DEFAULT_DECOMP_X_MIN`, `DEFAULT_DECOMP_X_MAX`] = [0.5, 18].
376///
377/// For the linear alternative (CJ2014 Appendix A Gram-Schmidt), call
378/// [`decompose_gram_schmidt`] directly. The two methods agree on μ and y to
379/// O(μ²) at realistic injection amplitudes (μ ≲ 10⁻³); they differ by a
380/// parameterisation-only offset δ_BF = δ_GS + μ/β_μ in the extracted ΔT/T.
381///
382/// Note: B&F absorbs μ inside the Bose-Einstein exponential, so the returned
383/// μ is the physical chemical potential (matching FIRAS-convention fits) —
384/// NOT Chluba's orthogonalised "M-shape" μ. The relation is μ_BF = μ_M to
385/// leading order; at μ ≳ 0.1 (rare in practice) the nonlinear BE shape
386/// diverges from linear M(x) and the methods materially differ.
387pub 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
391/// Convenience wrapper: returns (mu, y, delta_t_over_t) tuple.
392pub 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
397/// Number of grid points falling inside the default μ/y decomposition
398/// band [`DEFAULT_DECOMP_X_MIN`, `DEFAULT_DECOMP_X_MAX`].
399///
400/// `decompose_distortion` silently returns mu=y=0 when fewer than three
401/// grid points fall in the band, which is the right behaviour for the
402/// solver hot loop but a footgun for callers who set a custom (too-narrow)
403/// `x_min`/`x_max`. Solvers should sample this once at startup and surface
404/// a warning before running.
405pub 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
410/// FIRAS 95% CL upper limits on spectral distortion parameters.
411///
412/// Reference: Fixsen et al. (1996), ApJ 473, 576
413pub const FIRAS_MU_LIMIT: f64 = 9.0e-5;
414pub const FIRAS_Y_LIMIT: f64 = 1.5e-5;
415
416/// Check distortion parameters against FIRAS limits.
417/// Returns (mu_fraction, y_fraction) as fraction of the FIRAS limit.
418pub 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
425/// Convert distortion Δn(x) to specific intensity ΔI_ν in MJy/sr.
426///
427/// ΔI_ν = (2hν³/c²) Δn(x) = (2hν³/c²) Δn(x)
428///
429/// where ν = x k_B T_0 / h.
430pub 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    // Convert W/m²/Hz/sr → MJy/sr (1 MJy = 10^{-20} W/m²/Hz)
435    prefactor * delta_n * 1e20
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn test_decompose_pure_mu() {
444        // Create a pure μ-distortion and verify extraction.
445        // The energy-neutral basis fit over [1, 15] should recover μ well.
446        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        // y contamination should be negligible for a pure μ input
464        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        // μ contamination should be negligible for a pure y input
492        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        // A pure temperature-shift distortion Δn = (ΔT/T) × G(x)
526        // should be recovered with μ ≈ 0, y ≈ 0, ΔT/T ≈ dt_true.
527        //
528        // From the decomposition step 3:
529        //   ΔT/T = Δρ/ρ / 4 − μ/(4×1.401) − y
530        // For a pure G(x) input: Δρ/ρ = 4×(ΔT/T), so ΔT/T is recovered exactly
531        // from energy conservation, independent of the least-squares step.
532        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        // Temperature shift should be recovered to < 1%
544        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        // μ and y contamination should be negligible
552        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_decompose_convenience removed in 2026-04 triage: identical 5000-pt
567    // pure-μ setup as test_decompose_pure_mu; the only difference is calling
568    // `decompose()` (a 3-tuple convenience wrapper) instead of
569    // `decompose_distortion()`. Trivial wrapper test.)
570
571    #[test]
572    fn test_firas_check_values() {
573        let params = DistortionParams {
574            mu: 4.5e-5, // half the FIRAS limit
575            y: 7.5e-6,  // half the FIRAS limit
576            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(&params);
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        // At x=1 with T_cmb=2.726K, verify intensity has correct sign and magnitude
589        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        // Linearity: 2× Δn → 2× intensity
599        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        // Negative Δn → negative intensity
603        let neg = delta_n_to_intensity_mjy(1.0, -delta_n, t_cmb);
604        assert!(neg < 0.0);
605    }
606
607    // ============================================================================
608    // CJ2014 Gram-Schmidt and Bianchini-Fabbian nonlinear BE decomposition
609    // ============================================================================
610
611    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        // Pure Chluba-M has zero ΔT/T in the CJ2014 basis (by construction).
636        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        // For pure Chluba-M (photon-number conserving), both methods should
672        // give μ_BF = μ_GS = μ_true and ΔT/T_BF = μ/β_μ vs ΔT/T_GS = 0.
673        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        // y should be zero for pure Chluba-M; the nonlinear BE fit acquires
687        // a spurious y at O(μ²) from the Taylor remainder of n_BE(x+μ).
688        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        // Inject a true Bose-Einstein distortion Δn = n_BE(x+μ_true) − n_pl(x).
709        // B&F should recover μ_BF = μ_true and ΔT/T_BF ≈ 0.
710        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        // Gram-Schmidt on the SAME input should give the same μ but absorb
729        // the photon-number-carrying part into ΔT/T = −μ/β_μ.
730        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        // Realistic mixed distortion from the analytic Green's function at
752        // the μ-y crossover, where all three shapes are non-negligible.
753        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        // Predicted ΔT offset: δ_BF = δ_GS + μ/β_μ
773        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        // μ and y agree to within O(μ²) nonlinearity.
797        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        // ΔT/T offset: δ_BF = δ_GS + μ/β_μ
807        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}