spectroxide/
spectrum.rs

1//! Spectral shapes and distributions for CMB spectral distortions.
2//!
3//! All functions here take the dimensionless frequency `x = hν/(kT_z)` and
4//! return an occupation-number perturbation `Δn`. They are normalized so
5//! that the corresponding distortion amplitude (μ, y, ΔT/T) is the
6//! coefficient that multiplies the shape — e.g. `Δn_μ(x) = μ · M(x)`,
7//! `Δn_y(x) = y · Y_SZ(x)`, `Δn_T(x) = (ΔT/T) · G_bb(x)`.
8//!
9//! Provided shapes:
10//! - [`planck`] — equilibrium blackbody `n_pl(x) = 1/(e^x − 1)`
11//!   (small-`x` and large-`x` branches preserve precision).
12//! - [`bose_einstein`] — `1/(e^{x+μ} − 1)`, the chemical-potential family.
13//! - `mu_shape` — μ-distortion shape `M(x)` (Sunyaev-Zel'dovich 1970).
14//! - `y_shape` — y-distortion shape `Y_SZ(x)` (Zeldovich-Sunyaev 1969).
15//! - `temperature_shift_shape` — `G_bb(x) = x e^x / (e^x − 1)²`,
16//!   the temperature-shift mode `δT/T`.
17//!
18//! Conventions match those used by [`crate::greens`] and the Python
19//! `spectroxide.greens` module so PDE and Green's-function results can be
20//! decomposed against the same basis.
21
22use crate::constants::*;
23
24/// Planck (blackbody) occupation number: n_pl(x) = 1/(e^x - 1)
25#[inline]
26pub fn planck(x: f64) -> f64 {
27    if x < 1e-6 {
28        // Taylor expansion for small x: 1/x - 1/2 + x/12 - ...
29        1.0 / x - 0.5 + x / 12.0
30    } else if x > 500.0 {
31        (-x).exp()
32    } else {
33        // expm1 preserves precision near x = 0 where (e^x - 1) loses digits
34        // (cf. audit L4 in infra).
35        1.0 / x.exp_m1()
36    }
37}
38
39/// Bose-Einstein distribution: n_BE(x, μ) = 1/(e^(x+μ) - 1)
40#[inline]
41pub fn bose_einstein(x: f64, mu: f64) -> f64 {
42    let y = x + mu;
43    if y < 1e-6 {
44        1.0 / y - 0.5 + y / 12.0
45    } else if y > 500.0 {
46        (-y).exp()
47    } else {
48        1.0 / y.exp_m1()
49    }
50}
51
52/// Blackbody derivative: G_bb(x) = x e^x / (e^x - 1)²
53/// This is -x ∂n_pl/∂x = x²/(4T) ∂B_ν/∂T normalized
54#[inline]
55pub fn g_bb(x: f64) -> f64 {
56    if x < 1e-6 {
57        1.0 / x - 1.0 / 12.0 * x
58    } else if x > 500.0 {
59        x * (-x).exp()
60    } else {
61        // G_bb(x) = x e^x / (e^x - 1)² = x / (e^x - 1) × e^x / (e^x - 1)
62        //        = x · n_pl · (1 + n_pl), but here we take the direct form.
63        let em1 = x.exp_m1();
64        x * (1.0 + em1) / (em1 * em1)
65    }
66}
67
68/// μ-distortion spectral shape: M(x) = (x/β_μ - 1) · e^x / (e^x - 1)²
69///
70/// The chemical potential distortion; crosses zero at x = β_μ ≈ 2.19.
71/// Normalized such that μ = 1.401 × (Δρ/ρ) for energy injection in μ-era.
72/// Reference: Chluba (2013), Eq. (5b).
73#[inline]
74pub fn mu_shape(x: f64) -> f64 {
75    (x / BETA_MU - 1.0) * g_bb(x) / x
76}
77
78/// Y-distortion (Sunyaev-Zeldovich) spectral shape:
79/// Y_SZ(x) = G_bb(x) · [x·coth(x/2) - 4]
80///
81/// This is the classic SZ spectral function; crosses zero at x ≈ 3.83.
82/// Reference: Zeldovich & Sunyaev (1969).
83#[inline]
84pub fn y_shape(x: f64) -> f64 {
85    if x < 1e-6 {
86        // Small-x expansion: G_bb ≈ 1/x - x/12, x·coth(x/2) - 4 ≈ -2 + x²/6.
87        // Product: (1/x)(-2) + (1/x)(x²/6) + (-x/12)(-2) = -2/x + x/6 + x/6 = -2/x + x/3.
88        -2.0 / x + x / 3.0
89    } else {
90        let coth_half = (x / 2.0).cosh() / (x / 2.0).sinh();
91        g_bb(x) * (x * coth_half - 4.0)
92    }
93}
94
95/// Numerical integral of x^n * n_pl(x) over [0, x_max] using the trapezoidal rule
96/// on a logarithmic grid. Used for validation against analytic G_n values.
97pub fn spectral_integral(n: i32, x_min: f64, x_max: f64, num_points: usize) -> f64 {
98    // Use log-spaced grid for better accuracy at low x
99    let log_min = x_min.ln();
100    let log_max = x_max.ln();
101    let dlog = (log_max - log_min) / (num_points as f64 - 1.0);
102
103    let mut result = 0.0;
104    let mut prev_f = 0.0;
105    let mut prev_x = 0.0;
106
107    for i in 0..num_points {
108        let log_x = log_min + i as f64 * dlog;
109        let x = log_x.exp();
110        let f = x.powi(n) * planck(x);
111
112        if i > 0 {
113            result += 0.5 * (prev_f + f) * (x - prev_x);
114        }
115        prev_f = f;
116        prev_x = x;
117    }
118    result
119}
120
121/// Compute the Compton equilibrium temperature ratio T_e^eq / T_z
122/// from the photon spectrum.
123///
124/// T_e^eq = (I₄ / (4 G₃)) T_z where I₄ = ∫x⁴ n(1+n) dx, G₃ = ∫x³ n dx.
125/// For a Planck spectrum, T_e^eq = T_z exactly.
126pub fn compton_equilibrium_ratio(x_grid: &[f64], n: &[f64]) -> f64 {
127    let mut g3 = 0.0;
128    let mut i4 = 0.0;
129
130    for i in 1..x_grid.len() {
131        let dx = x_grid[i] - x_grid[i - 1];
132        let x_mid = 0.5 * (x_grid[i] + x_grid[i - 1]);
133        let n_mid = 0.5 * (n[i] + n[i - 1]);
134
135        let n_l = n[i - 1];
136        let n_r = n[i];
137        let nn1_mid = 0.5 * (n_l * (1.0 + n_l) + n_r * (1.0 + n_r));
138        g3 += x_mid.powi(3) * n_mid * dx;
139        i4 += x_mid.powi(4) * nn1_mid * dx;
140    }
141
142    if g3.abs() < 1e-300 {
143        return 1.0;
144    }
145    i4 / (4.0 * g3)
146}
147
148/// Trapezoidal integral of x^power × Δn over the grid, divided by norm.
149fn weighted_integral(x_grid: &[f64], delta_n: &[f64], power: i32, norm: f64) -> f64 {
150    let mut integral = 0.0;
151    for i in 1..x_grid.len() {
152        let dx = x_grid[i] - x_grid[i - 1];
153        let x_mid = 0.5 * (x_grid[i] + x_grid[i - 1]);
154        let dn_mid = 0.5 * (delta_n[i] + delta_n[i - 1]);
155        integral += x_mid.powi(power) * dn_mid * dx;
156    }
157    integral / norm
158}
159
160/// Compute fractional energy in distortion: Δρ/ρ = ∫x³ Δn dx / G₃
161pub fn delta_rho_over_rho(x_grid: &[f64], delta_n: &[f64]) -> f64 {
162    weighted_integral(x_grid, delta_n, 3, G3_PLANCK)
163}
164
165/// Compute fractional photon number change: ΔN/N = ∫x² Δn dx / G₂
166pub fn delta_n_over_n(x_grid: &[f64], delta_n: &[f64]) -> f64 {
167    weighted_integral(x_grid, delta_n, 2, G2_PLANCK)
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    /// Planck identity: dn_pl/dx + n_pl(1 + n_pl) = 0 exactly.
175    ///
176    /// This cancellation underpins the Kompaneets flux-split (CLAUDE.md
177    /// pitfall #1). Using finite differences would introduce O(dx²) ≈ 3e-3
178    /// error — ~1000× the physical signal. The identity must hold to machine
179    /// precision at every x.
180    ///
181    /// Derivation: n_pl = 1/(eˣ-1) ⇒ dn_pl/dx = -eˣ/(eˣ-1)².
182    /// n_pl(1+n_pl) = [1/(eˣ-1)]·[eˣ/(eˣ-1)] = eˣ/(eˣ-1)². Sum = 0.
183    #[test]
184    fn test_planck_identity_analytical() {
185        for &x in &[0.01, 0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0] {
186            let n = planck(x);
187            let ex = x.exp();
188            // Analytical dn_pl/dx
189            let dn_dx = -ex / (ex - 1.0).powi(2);
190            let rhs = n * (1.0 + n);
191            // The identity: dn_dx + rhs = 0
192            let residual = dn_dx + rhs;
193            // Relative error against the magnitude of either term.
194            // Threshold 1e-13: comfortably below the O(dx²)~3e-3 error that a
195            // finite-difference implementation would produce, while allowing
196            // for ~few ulps from the two exp() evaluations.
197            let rel_err = residual.abs() / rhs.abs().max(f64::MIN_POSITIVE);
198            assert!(
199                rel_err < 1e-13,
200                "Planck identity violated at x={x}: dn_dx={dn_dx:.6e}, \
201                 n(1+n)={rhs:.6e}, residual={residual:.6e}, rel_err={rel_err:.3e}"
202            );
203        }
204    }
205
206    #[test]
207    fn test_planck_low_x() {
208        // n_pl(x) ≈ 1/x for x → 0
209        let x = 1e-8;
210        let n = planck(x);
211        assert!((n - 1.0 / x).abs() / (1.0 / x) < 1e-6);
212    }
213
214    #[test]
215    fn test_planck_moderate_x() {
216        let x = 1.0;
217        let expected = 1.0 / (1.0_f64.exp() - 1.0);
218        assert!((planck(x) - expected).abs() < 1e-14);
219    }
220
221    #[test]
222    fn test_bose_einstein_reduces_to_planck() {
223        let x = 3.0;
224        assert!((bose_einstein(x, 0.0) - planck(x)).abs() < 1e-14);
225    }
226
227    #[test]
228    fn test_mu_shape_sign_change_at_beta_mu() {
229        // M(β_μ) = 0 is algebraic (factor (x/β_μ − 1) in the definition), so
230        // asserting |M(β_μ)| < tol is tautological. Test the *physical* claim
231        // instead: M changes sign across β_μ (negative on the low-x side,
232        // positive on the high-x side). Reference: Chluba & Sunyaev 2012,
233        // MNRAS 419, 1294.
234        let eps = 1e-3;
235        let m_below = mu_shape(BETA_MU - eps);
236        let m_above = mu_shape(BETA_MU + eps);
237        assert!(
238            m_below < 0.0 && m_above > 0.0,
239            "M should flip sign at β_μ: M({:.4})={m_below:.3e}, M({:.4})={m_above:.3e}",
240            BETA_MU - eps,
241            BETA_MU + eps
242        );
243    }
244
245    #[test]
246    fn test_y_shape_zero_crossing() {
247        // Y_SZ crosses zero at x ≈ 3.83
248        // Find it by bisection
249        let mut x_lo = 3.5_f64;
250        let mut x_hi = 4.2_f64;
251        for _ in 0..100 {
252            let x_mid = (x_lo + x_hi) / 2.0;
253            if y_shape(x_mid) > 0.0 {
254                x_hi = x_mid;
255            } else {
256                x_lo = x_mid;
257            }
258        }
259        let x_zero = (x_lo + x_hi) / 2.0;
260        assert!(
261            (x_zero - 3.83).abs() < 0.01,
262            "Y_SZ zero at x = {x_zero}, expected ~3.83"
263        );
264    }
265
266    #[test]
267    fn test_spectral_integral_g3() {
268        // ∫x³ n_pl dx = π⁴/15
269        let g3 = spectral_integral(3, 1e-6, 80.0, 100_000);
270        let expected = std::f64::consts::PI.powi(4) / 15.0;
271        let rel_err = (g3 - expected).abs() / expected;
272        assert!(
273            rel_err < 1e-6,
274            "G₃ = {g3}, expected {expected}, rel_err = {rel_err}"
275        );
276    }
277
278    #[test]
279    fn test_spectral_integral_g2() {
280        // ∫x² n_pl dx = 2ζ(3)
281        let g2 = spectral_integral(2, 1e-6, 80.0, 100_000);
282        let expected = G2_PLANCK;
283        let rel_err = (g2 - expected).abs() / expected;
284        assert!(
285            rel_err < 1e-5,
286            "G₂ = {g2}, expected {expected}, rel_err = {rel_err}"
287        );
288    }
289
290    #[test]
291    fn test_g_bb_all_branches() {
292        // Low-x branch: x < 1e-6
293        let x_low = 1e-8;
294        let g_low = g_bb(x_low);
295        assert!(
296            (g_low - 1.0 / x_low).abs() / (1.0 / x_low) < 1e-2,
297            "g_bb({x_low}) = {g_low}, expected ~1/x"
298        );
299
300        // High-x branch: x > 500
301        let x_high = 600.0;
302        let g_high = g_bb(x_high);
303        let expected = x_high * (-x_high).exp();
304        assert!(
305            (g_high - expected).abs() < 1e-250,
306            "g_bb({x_high}) = {g_high}, expected {expected}"
307        );
308
309        // Normal branch: 1e-6 < x < 500
310        let x_mid = 3.0;
311        let g_mid = g_bb(x_mid);
312        let ex = x_mid.exp();
313        let expected_mid = x_mid * ex / (ex - 1.0).powi(2);
314        assert!(
315            (g_mid - expected_mid).abs() < 1e-14,
316            "g_bb({x_mid}) = {g_mid}, expected {expected_mid}"
317        );
318    }
319
320    #[test]
321    fn test_planck_high_x_branch() {
322        let x = 600.0;
323        let n = planck(x);
324        assert!((n - (-x).exp()).abs() < 1e-250, "planck({x}) = {n}");
325    }
326
327    #[test]
328    fn test_bose_einstein_branches() {
329        // Low-x branch
330        let x_low = 1e-8;
331        let mu = 0.0;
332        let n = bose_einstein(x_low, mu);
333        assert!((n - planck(x_low)).abs() < 1e-6, "BE ≈ Planck at mu=0");
334
335        // High-x branch
336        let n_high = bose_einstein(500.0, 1.0);
337        assert!((n_high - (-501.0_f64).exp()).abs() < 1e-200);
338    }
339
340    #[test]
341    fn test_delta_rho_over_rho_and_delta_n_over_n() {
342        // For a known μ-distortion, Δρ/ρ = μ/1.401
343        let n_pts = 5000;
344        let x_min: f64 = 1e-4;
345        let x_max: f64 = 50.0;
346        let log_min = x_min.ln();
347        let log_max = x_max.ln();
348        let x_grid: Vec<f64> = (0..n_pts)
349            .map(|i| (log_min + (log_max - log_min) * i as f64 / (n_pts - 1) as f64).exp())
350            .collect();
351
352        // Pure temperature shift: Δn = ΔT/T × G_bb
353        let dt_over_t = 1e-5;
354        let dn: Vec<f64> = x_grid.iter().map(|&x| dt_over_t * g_bb(x)).collect();
355
356        let drho = delta_rho_over_rho(&x_grid, &dn);
357        // For G_bb: ∫x³ G_bb dx = 4G₃, so Δρ/ρ = 4 ΔT/T
358        assert!(
359            (drho - 4.0 * dt_over_t).abs() / (4.0 * dt_over_t) < 0.01,
360            "Δρ/ρ = {drho:.4e}, expected {:.4e}",
361            4.0 * dt_over_t
362        );
363
364        let dn_n = delta_n_over_n(&x_grid, &dn);
365        // For G_bb: ∫x² G_bb dx = 3G₂, so ΔN/N = 3 ΔT/T
366        assert!(
367            (dn_n - 3.0 * dt_over_t).abs() / (3.0 * dt_over_t) < 0.01,
368            "ΔN/N = {dn_n:.4e}, expected {:.4e}",
369            3.0 * dt_over_t
370        );
371    }
372
373    #[test]
374    fn test_compton_equilibrium_planck() {
375        // For Planck spectrum, T_e^eq / T_z = 1
376        let n_points = 5000;
377        let x_min = 1e-4_f64;
378        let x_max = 50.0_f64;
379        let log_min = x_min.ln();
380        let log_max = x_max.ln();
381        let x_grid: Vec<f64> = (0..n_points)
382            .map(|i| (log_min + (log_max - log_min) * i as f64 / (n_points - 1) as f64).exp())
383            .collect();
384        let n_vals: Vec<f64> = x_grid.iter().map(|&x| planck(x)).collect();
385
386        let ratio = compton_equilibrium_ratio(&x_grid, &n_vals);
387        assert!(
388            (ratio - 1.0).abs() < 1e-3,
389            "T_e^eq/T_z = {ratio}, expected 1.0 for Planck"
390        );
391    }
392}