spectroxide/
greens.rs

1//! Green's function for the cosmological thermalization problem.
2//!
3//! Provides a fast, approximate method for computing spectral distortions
4//! from arbitrary energy release histories. The distortion from a delta-function
5//! energy injection at redshift z_h is decomposed into μ, y, and temperature
6//! shift components using visibility/branching functions.
7//!
8//! The Green's function approach is "quasi-exact" for small distortions
9//! (Δρ/ρ << 1) and much faster than solving the full PDE.
10//!
11//! Also includes the **photon injection** Green's function (Chluba 2015),
12//! which handles injection of photons at a specific frequency x_inj.
13//! Unlike pure energy injection, photon injection changes both energy
14//! and number, producing negative μ when x_inj < x₀ ≈ 3.60.
15//!
16//! # Energy non-conservation in the three-component ansatz
17//!
18//! The temperature-shift coefficient uses J_T = 1 − J_bb* following Chluba (2013).
19//! The y-component uses the independently fitted J_y of Chluba (2013) Eq. 5,
20//! which is NOT simply (1 − J_μ) × J_bb*. As a result, the three branching
21//! ratios do not sum to unity:
22//!
23//!   J_μ × J_bb* + J_y + (1 − J_bb*) ≠ 1
24//!
25//! Chluba (2013) §3 notes that the "missing" energy in this ansatz stays within
26//! the residual and never exceeds ~16–17%, maximised in the μ-y transition
27//! region (z ~ 7–8×10⁴). Using the independent J_y fit matches PDE results
28//! more closely than the strictly energy-conserving choice J_y = (1 − J_μ) · J_bb*.
29//! Callers that need strict energy conservation must use the full PDE solver.
30//!
31//! References:
32//! - Chluba (2013), MNRAS 436, 2232 [arXiv:1304.6120], Eqs. 5–6 for J_μ, J_y, G_th
33//! - Chluba (2015), MNRAS 454, 4182 [arXiv:1506.06582], Eq. 13 for J_bb*
34//! - Chluba & Jeong (2014), MNRAS 438, 2065
35
36use crate::constants::*;
37use crate::cosmology::Cosmology;
38use crate::spectrum::{g_bb, mu_shape, y_shape};
39
40/// Thermalization visibility: probability that energy injection at z is
41/// fully thermalized into a blackbody (temperature shift).
42///
43/// J_bb(z) = exp(−(z/z_μ)^{5/2})
44///
45/// where z_μ ≈ 1.98×10⁶. Both z_μ and the exponent 5/2 are analytically
46/// derived: z_μ from equating the DC+BR photon production rate to the Hubble
47/// rate in radiation domination (Chluba & Sunyaev 2012), and 5/2 from the
48/// redshift scaling of the DC opacity ∝ (1+z)^{−9/2} vs H ∝ (1+z)^{−2}
49/// (Danese & de Zotti 1982; Hu & Silk 1993). These are NOT fit parameters.
50pub fn visibility_j_bb(z: f64) -> f64 {
51    let ratio = z / Z_MU;
52    (-ratio.powf(2.5)).exp()
53}
54
55/// Improved thermalization visibility with correction factor.
56///
57/// J_bb*(z) = 0.983 · J_bb(z) · (1 − 0.0381 (z/z_μ)^{2.29})
58///
59/// Chluba (2015), arXiv:1506.06582, Eq. 13. Valid for 3×10⁵ ≲ z ≲ 6×10⁶ in
60/// the standard cosmology (Chluba 2014 fit, neglecting relativistic temperature
61/// corrections which become noticeable at z_i ≳ 4×10⁶). The prefactor 0.983
62/// absorbs the small residual blackbody mismatch during the μ-era. The base
63/// J_bb exponent (5/2) and z_μ are analytically derived (see `visibility_j_bb`).
64pub fn visibility_j_bb_star(z: f64) -> f64 {
65    let ratio = z / Z_MU;
66    // Clamp at 0: the correction factor goes negative for z/z_mu ≳ 3.9,
67    // outside the fit's range of validity. Physically J_bb* ∈ [0, 1].
68    (0.983 * visibility_j_bb(z) * (1.0 - 0.0381 * ratio.powf(2.29))).max(0.0)
69}
70
71/// y-distortion branching ratio: fraction of energy going into y-type distortion.
72///
73/// J_y(z) = [1 + ((1+z)/6.0×10⁴)^{2.58}]^{−1}
74///
75/// Functional form from Chluba (2013), arXiv:1304.6120, Eq. 5. The denominator
76/// constant here (6.0×10⁴) comes from the updated refit in Arsenadze et al.
77/// (2025), arXiv:2502.11432; the original Chluba 2013 value was 5.9×10⁴, a
78/// ~1–2 % shift across the transition region. We keep 6.0×10⁴ for consistency
79/// with the J_μ/(1-J_μ) transition scale and with CosmoTherm GF tables.
80/// Approaches 1 for z ≪ 6×10⁴ and 0 for z ≫ 6×10⁴. Note: J_y ≠ (1 − J_μ) in
81/// the transition region; using the independent fit gives better spectral
82/// agreement with PDE results.
83pub fn visibility_j_y(z: f64) -> f64 {
84    1.0 / (1.0 + ((1.0 + z) / 6.0e4).powf(2.58))
85}
86
87/// μ-distortion branching ratio: fraction of energy going into μ-type distortion.
88///
89/// J_μ(z) = 1 − exp(−((1+z)/5.8×10⁴)^{1.88})
90///
91/// Chluba (2013), arXiv:1304.6120, Eq. 5. Approaches 0 for z ≪ 5.8×10⁴ (no μ)
92/// and 1 for z ≫ 5.8×10⁴ (pure μ). The transition scale is physically motivated
93/// by y_γ(z) ≈ 1, but the precise value and exponent are fit parameters.
94pub fn visibility_j_mu(z: f64) -> f64 {
95    1.0 - (-((1.0 + z) / 5.8e4).powf(1.88)).exp()
96}
97
98/// Temperature shift branching: fraction of energy going into temperature shift.
99///
100/// J_T(z) = 1 − J_bb*(z)
101///
102/// Following Chluba (2013), the temperature-shift coefficient is
103/// (1 − J_bb*), which captures the fraction of energy fully thermalized
104/// into a blackbody. This is the standard convention used in the literature.
105pub fn visibility_j_t(z: f64) -> f64 {
106    1.0 - visibility_j_bb_star(z)
107}
108
109/// Compute the Green's function G_th(x, z_h) for a delta-function energy injection
110/// at redshift z_h, observed at z = 0.
111///
112/// The three-component decomposition from Chluba (2013), Eq. 6:
113///
114///   G_th = (3/κ_c) · J_μ · J_bb* · M(x) + J_y/4 · Y_SZ(x) + (1−J_bb*)/4 · G(x)
115///
116/// where J_μ, J_y, and J_bb* are independently fitted visibility functions,
117/// and the temperature-shift coefficient (1 − J_bb*)/4 follows the Chluba (2013)
118/// convention.
119///
120/// Returns the distortion Δn(x) per unit Δρ/ρ injected.
121///
122/// # Accuracy
123///
124/// Per-point spectral shape vs PDE (worst-case over frequency grid):
125/// - **Deep μ-era** (z_h > 2×10⁵): < 17% per-point; < 5% on integrated μ.
126/// - **y-era** (z_h < 10⁴): < 5% per-point; < 1% on integrated y.
127/// - **Transition era** (z_h ~ 3×10⁴ – 10⁵): 8–17% per-point shape error
128///   (improved from 30–70% by using the independently fitted J_y instead of
129///   1 − J_μ). Integrated μ and y agree with PDE to ~5–10%.
130///
131/// References:
132///   Chluba (2013), MNRAS 436, 2232 [arXiv:1304.6120], Eq. 6
133///   Arsenadze et al. (2025), JHEP 03, 018 [arXiv:2409.12940], Appendix D
134pub fn greens_function(x: f64, z_h: f64) -> f64 {
135    let j_mu = visibility_j_mu(z_h);
136    let j_bb_star = visibility_j_bb_star(z_h);
137    let j_y = visibility_j_y(z_h);
138
139    let mu_part = (3.0 / KAPPA_C) * j_mu * j_bb_star * mu_shape(x);
140    let y_part = 0.25 * j_y * y_shape(x);
141    let t_part = 0.25 * (1.0 - j_bb_star) * g_bb(x);
142
143    mu_part + y_part + t_part
144}
145
146/// Compute the spectral distortion from an arbitrary energy release history.
147///
148/// ΔI(x) = ∫ G_th(x, z') · d(Q/ρ_γ)/dz' dz'
149///
150/// # Arguments
151/// * `x_grid` - frequency grid
152/// * `dq_dz` - function giving d(Δρ/ρ_γ)/dz at each redshift
153/// * `z_min` - minimum integration redshift
154/// * `z_max` - maximum integration redshift
155/// * `n_z` - number of redshift integration points
156///
157/// Returns Δn(x) at each frequency grid point.
158pub fn distortion_from_heating<F>(
159    x_grid: &[f64],
160    dq_dz: F,
161    z_min: f64,
162    z_max: f64,
163    n_z: usize,
164) -> Vec<f64>
165where
166    F: Fn(f64) -> f64,
167{
168    let n_x = x_grid.len();
169    let mut delta_n = vec![0.0; n_x];
170
171    // Integrate in ln(1+z) for numerical stability
172    let ln_min = (1.0 + z_min).ln();
173    let ln_max = (1.0 + z_max).ln();
174    let dln = (ln_max - ln_min) / (n_z - 1).max(1) as f64;
175
176    for j in 0..n_z {
177        let ln_1pz = ln_min + j as f64 * dln;
178        let z = ln_1pz.exp() - 1.0;
179        let dz_dln = 1.0 + z; // d(z)/d(ln(1+z)) = 1+z
180
181        let heating = dq_dz(z) * dz_dln;
182
183        if heating.abs() < 1e-50 {
184            continue;
185        }
186
187        // Trapezoidal weight
188        let w = if j == 0 || j == n_z - 1 {
189            0.5 * dln
190        } else {
191            dln
192        };
193
194        // Hoist z-only visibility functions out of x-loop
195        let j_mu = visibility_j_mu(z);
196        let j_bb_star = visibility_j_bb_star(z);
197        let j_y = visibility_j_y(z);
198        let coeff_mu = (3.0 / KAPPA_C) * j_mu * j_bb_star;
199        let coeff_y = 0.25 * j_y;
200        let coeff_t = 0.25 * (1.0 - j_bb_star);
201        let hw = heating * w;
202
203        for i in 0..n_x {
204            let x = x_grid[i];
205            let g = coeff_mu * mu_shape(x) + coeff_y * y_shape(x) + coeff_t * g_bb(x);
206            delta_n[i] += g * hw;
207        }
208    }
209
210    delta_n
211}
212
213/// Extract μ parameter from the Green's function approximation.
214///
215/// μ = (3/κ_c) ∫ J_bb*(z) · J_μ(z) · d(Δρ/ρ)/dz dz
216///
217/// Calls [`mu_y_from_heating`] internally and returns the μ component.
218/// For simultaneous μ and y, use [`mu_y_from_heating`] directly.
219pub fn mu_from_heating<F>(dq_dz: F, z_min: f64, z_max: f64, n_z: usize) -> f64
220where
221    F: Fn(f64) -> f64,
222{
223    mu_y_from_heating(dq_dz, z_min, z_max, n_z).0
224}
225
226/// Extract y parameter from the Green's function approximation.
227///
228/// y = (1/4) ∫ J_y(z) · d(Δρ/ρ)/dz dz
229///
230/// Uses the independently fitted J_y visibility function (Arsenadze et al. 2025),
231/// which gives better agreement with PDE results than (1 − J_μ).
232///
233/// Calls [`mu_y_from_heating`] internally and returns the y component.
234/// For simultaneous μ and y, use [`mu_y_from_heating`] directly.
235pub fn y_from_heating<F>(dq_dz: F, z_min: f64, z_max: f64, n_z: usize) -> f64
236where
237    F: Fn(f64) -> f64,
238{
239    mu_y_from_heating(dq_dz, z_min, z_max, n_z).1
240}
241
242/// Compute both μ and y from an arbitrary energy release history in a single pass.
243///
244/// This is more efficient than calling `mu_from_heating` and `y_from_heating`
245/// separately, as it evaluates the visibility functions only once per z-step.
246///
247/// Returns (μ, y).
248pub fn mu_y_from_heating<F>(dq_dz: F, z_min: f64, z_max: f64, n_z: usize) -> (f64, f64)
249where
250    F: Fn(f64) -> f64,
251{
252    let ln_min = (1.0 + z_min).ln();
253    let ln_max = (1.0 + z_max).ln();
254    let dln = (ln_max - ln_min) / (n_z - 1).max(1) as f64;
255
256    let mut mu = 0.0;
257    let mut y = 0.0;
258    for j in 0..n_z {
259        let ln_1pz = ln_min + j as f64 * dln;
260        let z = ln_1pz.exp() - 1.0;
261        let dz_dln = 1.0 + z;
262        let heating = dq_dz(z) * dz_dln;
263
264        let w = if j == 0 || j == n_z - 1 {
265            0.5 * dln
266        } else {
267            dln
268        };
269        let hw = heating * w;
270
271        let j_mu = visibility_j_mu(z);
272        let j_bb_star = visibility_j_bb_star(z);
273        mu += (3.0 / KAPPA_C) * j_bb_star * j_mu * hw;
274        y += 0.25 * visibility_j_y(z) * hw;
275    }
276
277    (mu, y)
278}
279
280// ============================================================================
281// Photon injection Green's function
282// ============================================================================
283//
284// For photon injection at frequency x_inj, both photon number AND energy
285// change. The resulting distortion depends on x_inj relative to the balanced
286// frequency x₀ ≈ 3.60:
287//
288//   - x_inj > x₀: positive μ (like energy injection)
289//   - x_inj < x₀: negative μ (opposite sign!)
290//   - x_inj = x₀: zero μ
291//
292// The formalism introduces a photon survival probability P_s(x, z) that
293// captures absorption of soft photons by DC/BR processes.
294//
295// References:
296//   Chluba (2015), arXiv:1506.06582
297//   Arsenadze et al. (2025), arXiv:2409.12940, Appendix C+D
298
299/// Critical frequency for double Compton absorption.
300///
301/// x_c_DC(z) = 8.60×10⁻³ × [(1+z)/(2×10⁶)]^{1/2}
302///
303/// Reference: Chluba (2015), arXiv:1506.06582, Eq. 25a
304pub fn x_c_dc(z: f64) -> f64 {
305    8.60e-3 * ((1.0 + z) / 2.0e6).powf(0.5)
306}
307
308/// Critical frequency for bremsstrahlung absorption.
309///
310/// x_c_BR(z) = 1.23×10⁻³ × [(1+z)/(2×10⁶)]^{−0.672}
311///
312/// Reference: Chluba (2015), arXiv:1506.06582, Eq. 25b
313pub fn x_c_br(z: f64) -> f64 {
314    1.23e-3 * ((1.0 + z) / 2.0e6).powf(-0.672)
315}
316
317/// Combined critical frequency for photon absorption.
318///
319/// x_c² = x_c_DC² + x_c_BR²  (quadrature addition)
320///
321/// Photons with x << x_c are absorbed by DC/BR; x >> x_c survive.
322///
323/// Reference: Chluba (2015), arXiv:1506.06582, Eq. 25
324pub fn x_c(z: f64) -> f64 {
325    let dc = x_c_dc(z);
326    let br = x_c_br(z);
327    (dc * dc + br * br).sqrt()
328}
329
330/// Photon survival probability P_s(x, z).
331///
332/// P_s(x, z) = exp(−x_c(z)/x)
333///
334/// Probability that an injected photon at frequency x survives absorption
335/// by DC/BR processes. At high x (x >> x_c), P_s → 1 (photon survives).
336/// At low x (x << x_c), P_s → 0 (photon is absorbed).
337///
338/// Reference: Chluba (2015), arXiv:1506.06582, Eq. 24
339pub fn photon_survival_probability(x: f64, z: f64) -> f64 {
340    if x < 1e-30 {
341        return 0.0;
342    }
343    let ratio = x_c(z) / x;
344    (-ratio).exp()
345}
346
347/// Photon survival probability: numerical τ_ff in the y-era, analytic at higher z.
348///
349/// At z ≤ 5×10⁴ (y-era): P_s = exp(−τ_ff) where τ_ff is the integrated DC+BR
350/// absorption optical depth (Chluba 2015, Eq. 29/32). The raw absorption integral
351/// is valid here because Compton scattering is weak (y_γ << 1).
352///
353/// At z > 5×10⁴ (μ-era): P_s = exp(−x_c/x), the quasi-stationary approximation
354/// which correctly accounts for Compton redistribution. The raw τ_ff integral
355/// would overestimate absorption here because it ignores Compton upscattering.
356fn photon_survival_probability_numerical(x: f64, z_h: f64, cosmo: &Cosmology) -> f64 {
357    // μ-era: use the analytic formula (accounts for Compton redistribution)
358    if z_h > 5.0e4 {
359        return photon_survival_probability(x, z_h);
360    }
361    tau_ff_survival(x, z_h, cosmo)
362}
363
364/// Compute P_s = exp(−τ_ff) from the integrated DC+BR absorption optical depth.
365///
366/// τ_ff(x, z_h) = ∫_{z_end}^{z_h} R(x,z) × dτ_Thomson/dz dz
367///
368/// where R = [K_DC + K_BR] × (e^x − 1) / x³ is the absorption rate per Thomson time
369/// and dτ_Thomson/dz = n_e σ_T c / [(1+z) H(z)].
370///
371/// Reference: Chluba (2015), arXiv:1506.06582, Eq. 29
372fn tau_ff_survival(x: f64, z_h: f64, cosmo: &Cosmology) -> f64 {
373    use crate::bremsstrahlung::br_emission_coefficient_with_he;
374    use crate::double_compton::dc_emission_coefficient;
375    use crate::recombination::{ionization_fraction, saha_he_i, saha_he_ii};
376
377    if x < 1e-30 {
378        return 0.0;
379    }
380
381    // Below recombination freeze-out, BR/DC are negligible
382    let z_end = 200.0;
383    if z_h <= z_end {
384        return 1.0;
385    }
386
387    let n_steps: usize = 500;
388    let log_z_h = z_h.ln();
389    let log_z_end = z_end.ln();
390    let d_log_z = (log_z_h - log_z_end) / n_steps as f64;
391
392    // Bose factor: (e^x - 1), precompute once since x is fixed. Overflow at
393    // x > 500 → +∞ propagates through `rate` and triggers the tau > 500 → 0
394    // saturation below. Using f64::MAX would pass finite-checks elsewhere.
395    let bose_factor = if x > 500.0 { f64::INFINITY } else { x.exp_m1() };
396    let inv_x3 = 1.0 / (x * x * x);
397
398    let mut tau = 0.0;
399
400    for i in 0..=n_steps {
401        let log_z = log_z_end + i as f64 * d_log_z;
402        let z = log_z.exp();
403        let opz = 1.0 + z;
404
405        // Use cosmology-aware θ_z so that a user-supplied T_CMB is honoured.
406        let tz = cosmo.theta_z(z);
407        let te = tz; // T_e ≈ T_z (valid in y-era and μ-era)
408
409        // Full ionization fraction including H + He (He²⁺ at z≳8000, He⁺ at
410        // 2000≲z≲8000, He⁰ after). Previous simplified-Saha branching omitted
411        // helium electrons at z>6000 and z>1500, under-counting n_e by ~7–8%.
412        let x_e_frac = ionization_fraction(z, cosmo);
413
414        let n_h = cosmo.n_h(z);
415        let n_he = cosmo.n_he(z);
416        let n_e = cosmo.n_e(z, x_e_frac);
417
418        // DC emission coefficient
419        let k_dc = dc_emission_coefficient(x, tz);
420
421        // BR emission coefficient (with precomputed He ionization)
422        let y_he_ii = saha_he_ii(z, cosmo);
423        let y_he_i = saha_he_i(z, cosmo);
424        let k_br = br_emission_coefficient_with_he(
425            x,
426            te,
427            tz,
428            n_h,
429            n_he,
430            n_e,
431            x_e_frac.min(1.0),
432            y_he_ii,
433            y_he_i,
434        );
435
436        // Absorption rate per Thomson time: R = K × (e^x - 1) / x³
437        let rate = (k_dc + k_br) * bose_factor * inv_x3;
438
439        // dτ_Thomson/dz = n_e σ_T c / [(1+z) H(z)]
440        let dtau_dz = n_e * SIGMA_THOMSON * C_LIGHT / (opz * cosmo.hubble(z));
441
442        // Trapezoidal weight: endpoints get 0.5
443        let weight = if i == 0 || i == n_steps { 0.5 } else { 1.0 };
444
445        // Accumulate: dz = z × d(log z)
446        tau += weight * rate * dtau_dz * z * d_log_z;
447    }
448
449    if tau > 500.0 {
450        return 0.0;
451    }
452    (-tau).exp()
453}
454
455// (Arsenadze x'-dependent transition table removed: not well supported by
456// physics. Photon injection now uses the universal J_μ(z) visibility.)
457
458// ---------------------------------------------------------------------------
459// Compton broadening helpers for surviving photon bump
460// ---------------------------------------------------------------------------
461
462/// Compton scattering helper: f(x) = exp(-x)(1 + x²/2).
463///
464/// Reference: Arsenadze et al. (2025), Eq. D14
465fn f_cs(x: f64) -> f64 {
466    (-x).exp() * (1.0 + x * x / 2.0)
467}
468
469/// Compton broadening parameter α(x', y_γ).
470///
471/// α = (3 − 2f(x')) / sqrt(1 + x'·y_γ)
472///
473/// Reference: Arsenadze et al. (2025), Eq. D13
474fn alpha_cs(x_inj: f64, yg: f64) -> f64 {
475    (3.0 - 2.0 * f_cs(x_inj)) / (1.0 + x_inj * yg).sqrt()
476}
477
478/// Compton broadening parameter β(x', y_γ).
479///
480/// β = 1 / (1 + x'·y_γ·(1 − f(x')))
481///
482/// Reference: Arsenadze et al. (2025), Eq. D13
483fn beta_cs(x_inj: f64, yg: f64) -> f64 {
484    1.0 / (1.0 + x_inj * yg * (1.0 - f_cs(x_inj)))
485}
486
487/// Compute the Compton-broadened photon bump and its energy integral f_int.
488///
489/// Returns (bump_value, f_int) where:
490/// - bump_value is the log-normal PDF at x_obs
491/// - f_int = exp((α+β)·y_γ) / (1 + x_inj·y_γ) is the mean energy ratio
492///
493/// For y_γ < 1e-6, falls back to a narrow Gaussian at x_inj.
494///
495/// Reference: Arsenadze et al. (2025), Eq. D15-D16
496fn broadened_bump(x_obs: f64, x_inj: f64, yg: f64) -> (f64, f64) {
497    if yg < 1e-6 {
498        // Negligible broadening: use narrow Gaussian
499        let sig = 0.005 * x_inj;
500        let norm = 1.0 / (sig * (2.0 * std::f64::consts::PI).sqrt());
501        let bump = (-(x_obs - x_inj).powi(2) / (2.0 * sig * sig)).exp() * norm;
502        return (bump, 1.0);
503    }
504
505    let alpha = alpha_cs(x_inj, yg);
506    let beta = beta_cs(x_inj, yg);
507    let denom = 1.0 + x_inj * yg;
508
509    // Log-normal parameters
510    let mu_ln = x_inj.ln() + alpha * yg - denom.ln();
511    let sigma_ln_sq = 2.0 * beta * yg;
512    let sigma_ln = sigma_ln_sq.max(1e-30).sqrt();
513
514    // f_int = exp((α+β)·y_γ) / (1 + x'·y_γ)
515    let f_int_arg = (alpha + beta) * yg;
516    let f_int = if f_int_arg < 700.0 {
517        f_int_arg.exp() / denom
518    } else {
519        700.0_f64.exp() / denom // Clamp to avoid infinity
520    };
521
522    // Log-normal bump
523    let bump = if x_obs > 1e-30 {
524        let ln_x = x_obs.ln();
525        let z_score = (ln_x - mu_ln) / sigma_ln;
526        (-0.5 * z_score * z_score).exp() / (x_obs * sigma_ln * (2.0 * std::f64::consts::PI).sqrt())
527    } else {
528        0.0
529    };
530
531    (bump, f_int)
532}
533
534/// Photon-injection GF is only valid in the deep μ-era (z_h ≳ 2×10⁵) or
535/// the y-era (z_h ≲ 5×10⁴). In the μ-y transition window, the simple
536/// μ+y decomposition misses residual (r-type) contributions; users must
537/// fall back to the PDE solver.
538const PHOTON_GF_Y_ERA_Z_MAX: f64 = 5.0e4;
539const PHOTON_GF_MU_ERA_Z_MIN: f64 = 2.0e5;
540
541#[inline]
542fn assert_photon_gf_regime(z_h: f64) {
543    // Fractional slack absorbs ln/exp roundoff at the boundaries.
544    const TOL: f64 = 1.0e-6;
545    let lo = PHOTON_GF_Y_ERA_Z_MAX * (1.0 + TOL);
546    let hi = PHOTON_GF_MU_ERA_Z_MIN * (1.0 - TOL);
547    assert!(
548        !(z_h > lo && z_h < hi),
549        "Photon Green's function is not valid in the μ-y transition era \
550         ({:.0e} < z_h < {:.0e}); got z_h = {:.3e}. Use the PDE solver instead.",
551        PHOTON_GF_Y_ERA_Z_MAX,
552        PHOTON_GF_MU_ERA_Z_MIN,
553        z_h
554    );
555}
556
557/// Whether `z_h` is inside the μ-y transition band where the photon GF is invalid.
558#[inline]
559fn in_photon_gf_transition_band(z_h: f64) -> bool {
560    const TOL: f64 = 1.0e-6;
561    let lo = PHOTON_GF_Y_ERA_Z_MAX * (1.0 + TOL);
562    let hi = PHOTON_GF_MU_ERA_Z_MIN * (1.0 - TOL);
563    z_h > lo && z_h < hi
564}
565
566/// Green's function for monochromatic photon injection.
567///
568/// Returns Δn(x_obs) per unit ΔN/N injected at frequency x_inj and
569/// redshift z_h. Uses the universal μ-y visibility function J_μ(z)
570/// (same as heat injection) to blend between pure μ-era and pure y-era
571/// contributions.
572///
573/// Structure:
574///
575///   G_ph = J_μ · G_μ + (1 − J_μ) · G_y
576///
577/// where G_μ is the μ-era contribution (M + G_bb terms) and G_y is the
578/// y-era contribution (Y_SZ + surviving bump).
579///
580/// When P_s = 0 (soft photon limit), reduces to α_ρ × x_inj × G_th(x, z_h).
581///
582/// # Arguments
583/// * `x_obs` - observation frequency
584/// * `x_inj` - injection frequency
585/// * `z_h` - injection redshift
586/// * `sigma_x` - extra Gaussian width for the surviving bump (0 for pure Compton)
587/// * `cosmo` - cosmological parameters (for Compton y_γ)
588///
589/// References:
590///   Chluba (2015), arXiv:1506.06582
591pub fn greens_function_photon(
592    x_obs: f64,
593    x_inj: f64,
594    z_h: f64,
595    sigma_x: f64,
596    cosmo: &Cosmology,
597) -> f64 {
598    assert_photon_gf_regime(z_h);
599    let j_bb_star = visibility_j_bb_star(z_h);
600    let p_s = photon_survival_probability_numerical(x_inj, z_h, cosmo);
601    let alpha_x = ALPHA_RHO * x_inj;
602
603    // Universal μ-y transition (same as heat injection GF)
604    let j_mu = visibility_j_mu(z_h);
605
606    // μ-era contribution (Arsenadze Eq. D9)
607    let mu_factor = 1.0 - p_s * X_BALANCED / x_inj;
608    let mu_part = (3.0 / KAPPA_C) * j_bb_star * mu_factor * mu_shape(x_obs);
609
610    // Temperature shift
611    let lam = 1.0 - mu_factor * j_bb_star;
612    let t_part = lam / 4.0 * g_bb(x_obs);
613
614    // Deep μ-era short-circuit: when J_μ ≈ 1, y-era contribution is zero
615    // and computing it risks f_int overflow for large y_γ.
616    if j_mu > 1.0 - 1e-12 {
617        return alpha_x * (mu_part + t_part);
618    }
619
620    // y-era contribution
621    let yg = cosmo.compton_y_parameter(z_h);
622
623    // Broadened surviving photon bump (log-normal from Compton scattering)
624    let (bump_shape, f_int) = broadened_bump(x_obs, x_inj, yg);
625
626    // Smooth y-era: energy balance coefficient
627    // (1 − P_s · f_int) accounts for energy shift of surviving photons
628    let coeff_y = 1.0 - p_s * f_int;
629    let y_smooth = coeff_y * 0.25 * y_shape(x_obs);
630
631    // Combine μ and y via universal visibility J_μ(z)
632    let mu_era = mu_part + t_part;
633    let y_era = y_smooth;
634    let smooth = alpha_x * (j_mu * mu_era + (1.0 - j_mu) * y_era);
635
636    // Surviving photon bump (broadened by Compton scattering).
637    // Use the precomputed bump_shape from broadened_bump() when sigma_x == 0.
638    // Only recompute when sigma_x > 0 (extra instrumental broadening).
639    let surviving = if x_obs > 1e-30 {
640        let safe_x = x_obs.max(1e-30);
641        let bump_final = if sigma_x > 0.0 {
642            if yg >= 1e-6 {
643                // Log-normal form with extra sigma_x added in quadrature
644                let alpha = alpha_cs(x_inj, yg);
645                let beta = beta_cs(x_inj, yg);
646                let denom = 1.0 + x_inj * yg;
647                let mu_ln = x_inj.ln() + alpha * yg - denom.ln();
648                let sigma_ln_sq = 2.0 * beta * yg + (sigma_x / x_inj).powi(2);
649                let sigma_ln = sigma_ln_sq.max(1e-30).sqrt();
650                let ln_x = safe_x.ln();
651                let z_score = (ln_x - mu_ln) / sigma_ln;
652                (-0.5 * z_score * z_score).exp()
653                    / (safe_x * sigma_ln * (2.0 * std::f64::consts::PI).sqrt())
654            } else {
655                // No Compton broadening: use Gaussian in x-space
656                let norm = 1.0 / (sigma_x * (2.0 * std::f64::consts::PI).sqrt());
657                (-(x_obs - x_inj).powi(2) / (2.0 * sigma_x * sigma_x)).exp() * norm
658            }
659        } else {
660            // No extra broadening: use precomputed bump from broadened_bump()
661            bump_shape
662        };
663        p_s * (1.0 - j_mu) * G2_PLANCK / (safe_x * safe_x) * bump_final
664    } else {
665        0.0
666    };
667
668    smooth + surviving
669}
670
671/// Compute μ from monochromatic photon injection at frequency x_inj.
672///
673/// μ = α_ρ × x_inj × (3/κ_c) × J*(z_h) × J_μ(z_h)
674///     × [1 − P_s × x₀/x_inj] × ΔN/N
675///
676/// Uses the universal J_μ(z) visibility function (same as heat injection).
677///
678/// Sign behavior:
679///   - x_inj > x₀ and P_s ≈ 1: μ > 0 (energy-dominated)
680///   - x_inj < x₀ and P_s ≈ 1: μ < 0 (number-dominated, negative μ!)
681///   - P_s ≈ 0 (soft photons absorbed): μ > 0 always (pure energy injection)
682///
683/// Reference: Chluba (2015), Eq. C7
684pub fn mu_from_photon_injection(x_inj: f64, z_h: f64, delta_n_over_n: f64) -> f64 {
685    assert_photon_gf_regime(z_h);
686    let j_bb_star = visibility_j_bb_star(z_h);
687    let j_mu = visibility_j_mu(z_h);
688    let p_s = photon_survival_probability(x_inj, z_h);
689
690    let mu_factor = 1.0 - p_s * X_BALANCED / x_inj;
691
692    ALPHA_RHO * x_inj * (3.0 / KAPPA_C) * j_bb_star * j_mu * mu_factor * delta_n_over_n
693}
694
695/// Compute spectral distortion from an arbitrary photon injection history.
696///
697/// ΔI(x) = ∫ G_ph(x, x_inj, z') × d(ΔN/N)/dz' dz'
698///
699/// This integrates the photon injection Green's function over the injection
700/// history, analogous to `distortion_from_heating` for energy injection.
701///
702/// # Arguments
703/// * `x_grid` - observation frequency grid
704/// * `x_inj` - injection frequency
705/// * `dn_dz` - function giving d(ΔN/N)/dz at each redshift
706/// * `z_min` - minimum integration redshift
707/// * `z_max` - maximum integration redshift
708/// * `n_z` - number of redshift integration points
709/// * `sigma_x` - extra Gaussian width for the surviving photon bump (0 for pure Compton)
710/// * `cosmo` - cosmological parameters (for Compton y_γ)
711pub fn distortion_from_photon_injection<F>(
712    x_grid: &[f64],
713    x_inj: f64,
714    dn_dz: F,
715    z_min: f64,
716    z_max: f64,
717    n_z: usize,
718    sigma_x: f64,
719    cosmo: &Cosmology,
720) -> Vec<f64>
721where
722    F: Fn(f64) -> f64,
723{
724    let n_x = x_grid.len();
725    let mut delta_n = vec![0.0; n_x];
726
727    let ln_min = (1.0 + z_min).ln();
728    let ln_max = (1.0 + z_max).ln();
729    let dln = (ln_max - ln_min) / (n_z - 1).max(1) as f64;
730
731    for j in 0..n_z {
732        let ln_1pz = ln_min + j as f64 * dln;
733        let z = ln_1pz.exp() - 1.0;
734        // Skip the μ-y transition band: `greens_function_photon` panics there.
735        // When the integration window crosses the band, the integral is
736        // mildly underestimated — but a panic mid-integration would lose all
737        // accumulated work, so the trade is correct.
738        if in_photon_gf_transition_band(z) {
739            continue;
740        }
741        let dz_dln = 1.0 + z;
742
743        let source = dn_dz(z) * dz_dln;
744        if source.abs() < 1e-50 {
745            continue;
746        }
747
748        let w = if j == 0 || j == n_z - 1 {
749            0.5 * dln
750        } else {
751            dln
752        };
753
754        for i in 0..n_x {
755            delta_n[i] += greens_function_photon(x_grid[i], x_inj, z, sigma_x, cosmo) * source * w;
756        }
757    }
758
759    delta_n
760}
761
762/// Returns `true` iff the GF integration window crosses the μ-y transition
763/// band; callers can use this to emit a warning at function entry.
764pub fn integration_crosses_photon_gf_gap(z_min: f64, z_max: f64) -> bool {
765    let lo = PHOTON_GF_Y_ERA_Z_MAX;
766    let hi = PHOTON_GF_MU_ERA_Z_MIN;
767    z_min < hi && z_max > lo
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773
774    #[test]
775    fn test_greens_high_z_is_temperature_shift() {
776        // At z >> z_mu, the Green's function should be a temperature shift
777        // G_th ≈ (1/4) G(x) since J_bb* → 0 and J_y → 0
778        let z_h = 5.0e6;
779        let x = 3.0;
780        let g = greens_function(x, z_h);
781        let expected = 0.25 * g_bb(x);
782        assert!(
783            (g - expected).abs() / expected.abs().max(1e-20) < 0.01,
784            "At z={z_h}, G={g}, expected T-shift={expected}"
785        );
786    }
787
788    #[test]
789    fn test_greens_mu_era() {
790        // At z ~ 3×10⁵, should be dominated by μ-distortion
791        let z_h = 3.0e5;
792        let j_mu = visibility_j_mu(z_h);
793        let j_bb = visibility_j_bb_star(z_h);
794        // μ should be significant
795        assert!(j_mu > 0.5, "J_mu({z_h}) = {j_mu}, should be > 0.5");
796        assert!(j_bb > 0.5, "J_bb*({z_h}) = {j_bb}, should be > 0.5");
797    }
798
799    #[test]
800    fn test_greens_y_era() {
801        // At z ~ 5000, should be dominated by y-distortion
802        let z_h = 5.0e3;
803        let j_mu = visibility_j_mu(z_h);
804        assert!(j_mu < 0.1, "J_mu should be small at z={z_h}");
805    }
806
807    #[test]
808    fn test_mu_from_delta_injection() {
809        // Delta-function injection at z_h in the μ-era
810        // μ ≈ 1.401 × Δρ/ρ × J_bb*(z_h) × J_μ(z_h)
811        let z_h = 2.0e5;
812        let drho_rho = 1e-5;
813        let sigma_z = 5000.0; // wide enough to be resolved on the integration grid
814
815        let dq_dz = |z: f64| -> f64 {
816            drho_rho * (-(z - z_h).powi(2) / (2.0 * sigma_z * sigma_z)).exp()
817                / (2.0 * std::f64::consts::PI * sigma_z * sigma_z).sqrt()
818        };
819
820        let mu = mu_from_heating(dq_dz, 1e3, 5e6, 10000);
821        let expected =
822            (3.0 / KAPPA_C) * visibility_j_bb_star(z_h) * visibility_j_mu(z_h) * drho_rho;
823
824        let rel_err = (mu - expected).abs() / expected.abs().max(1e-20);
825        assert!(
826            rel_err < 0.05,
827            "μ = {mu:.3e}, expected {expected:.3e}, rel_err = {rel_err}"
828        );
829    }
830
831    // --- Photon injection Green's function tests ---
832
833    #[test]
834    fn test_x_c_dc_dominance_at_high_z() {
835        // At high z (thermalization era), DC should dominate over BR
836        let z = 2.0e6;
837        let dc = x_c_dc(z);
838        let br = x_c_br(z);
839        assert!(
840            dc > br,
841            "At z={z:.0e}: x_c_DC={dc:.4e} should > x_c_BR={br:.4e}"
842        );
843    }
844
845    #[test]
846    fn test_x_c_br_dominance_at_low_z() {
847        // At low z, BR should dominate over DC
848        let z = 1.0e4;
849        let dc = x_c_dc(z);
850        let br = x_c_br(z);
851        assert!(
852            br > dc,
853            "At z={z:.0e}: x_c_BR={br:.4e} should > x_c_DC={dc:.4e}"
854        );
855    }
856
857    #[test]
858    fn test_photon_survival_limits() {
859        let z = 2.0e5;
860        // High x: photon survives
861        let p_high = photon_survival_probability(10.0, z);
862        assert!(p_high > 0.99, "P_s(x=10, z=2e5) = {p_high}, should be ~1");
863
864        // Very low x: photon absorbed
865        let p_low = photon_survival_probability(1e-5, z);
866        assert!(p_low < 1e-10, "P_s(x=1e-5, z=2e5) = {p_low}, should be ~0");
867    }
868
869    #[test]
870    fn test_tau_ff_fallback_to_analytic_at_high_z() {
871        // Above z = 5e4, the numerical P_s falls back to the analytic
872        // formula (which accounts for Compton redistribution).
873        let cosmo = Cosmology::default();
874        let z = 2.0e5;
875        for &x in &[0.01, 0.1, 1.0] {
876            let ps_num = photon_survival_probability_numerical(x, z, &cosmo);
877            let ps_ana = photon_survival_probability(x, z);
878            assert!(
879                (ps_num - ps_ana).abs() < 1e-15,
880                "At z={z:.0e} > 5e4, numerical should equal analytic: \
881                 {ps_num:.6e} vs {ps_ana:.6e}"
882            );
883        }
884    }
885
886    #[test]
887    fn test_tau_ff_limits() {
888        let cosmo = Cosmology::default();
889        // P_s = 1 below recombination freeze-out
890        assert_eq!(
891            photon_survival_probability_numerical(1.0, 100.0, &cosmo),
892            1.0
893        );
894
895        // P_s → 1 for x >> x_c at moderate z
896        let ps_high_x = photon_survival_probability_numerical(10.0, 1e4, &cosmo);
897        assert!(
898            ps_high_x > 0.95,
899            "P_s(x=10, z=1e4) should be ~1: got {ps_high_x}"
900        );
901
902        // P_s → 0 for x << x_c at high z
903        let ps_low_x = photon_survival_probability_numerical(1e-5, 1e5, &cosmo);
904        assert!(
905            ps_low_x < 0.01,
906            "P_s(x=1e-5, z=1e5) should be ~0: got {ps_low_x}"
907        );
908    }
909
910    #[test]
911    fn test_mu_from_photon_injection_sign_flip() {
912        // At high x_inj > x₀ with P_s ≈ 1: positive μ (like energy injection)
913        // At low x_inj < x₀ with P_s ≈ 1: negative μ (unique to photon injection)
914        let z_h = 2.0e5;
915        let dn_over_n = 1e-5;
916
917        let mu_high = mu_from_photon_injection(10.0, z_h, dn_over_n);
918        assert!(
919            mu_high > 0.0,
920            "x_inj=10 > x₀: μ should be positive, got {mu_high:.4e}"
921        );
922
923        let mu_low = mu_from_photon_injection(2.0, z_h, dn_over_n);
924        assert!(
925            mu_low < 0.0,
926            "x_inj=2 < x₀: μ should be negative, got {mu_low:.4e}"
927        );
928    }
929
930    #[test]
931    fn test_mu_from_photon_injection_balanced() {
932        // At x_inj = x₀ with P_s ≈ 1: μ should be near zero
933        let z_h = 2.0e5;
934        let dn_over_n = 1e-5;
935        let mu = mu_from_photon_injection(X_BALANCED, z_h, dn_over_n);
936
937        // P_s at x₀ ≈ 3.6 is very close to 1 at z=2e5, so the zero
938        // should be very accurate
939        let p_s = photon_survival_probability(X_BALANCED, z_h);
940        assert!(p_s > 0.99, "P_s at x₀ should be ~1");
941
942        // The residual should be small (from P_s not being exactly 1)
943        let mu_scale = mu_from_photon_injection(10.0, z_h, dn_over_n).abs();
944        assert!(
945            mu.abs() < 0.01 * mu_scale,
946            "At x₀: |μ| = {:.4e} should be << {mu_scale:.4e}",
947            mu.abs()
948        );
949    }
950
951    #[test]
952    fn test_visibility_functions_physical_bounds() {
953        // All visibility functions must satisfy physical bounds and asymptotics.
954        // These are NOT tautological checks — they verify the fitting formulas
955        // produce physically sensible values.
956
957        for &z in &[1e3, 5e3, 1e4, 5e4, 1e5, 3e5, 1e6, 3e6] {
958            let jmu = visibility_j_mu(z);
959            let jbb = visibility_j_bb_star(z);
960            let jy = visibility_j_y(z);
961            let jt = visibility_j_t(z);
962
963            // Individual visibilities must be in [0, 1]
964            assert!(
965                jmu >= 0.0 && jmu <= 1.0,
966                "J_mu({z:.0e}) = {jmu} out of [0,1]"
967            );
968            assert!(
969                jbb >= 0.0 && jbb <= 1.0,
970                "J_bb*({z:.0e}) = {jbb} out of [0,1]"
971            );
972            assert!(jy >= 0.0 && jy <= 1.0, "J_y({z:.0e}) = {jy} out of [0,1]");
973            // J_T = 1 - J_mu*J_bb* - J_y can go negative (up to ~-15%) in the
974            // transition region (z ~ 5e4) where independently fitted J_y + J_mu*J_bb*
975            // slightly exceeds 1. This is absorbed into the unobservable temperature shift.
976            assert!(
977                jt >= -0.2 && jt <= 1.0,
978                "J_T({z:.0e}) = {jt} out of [-0.2,1]"
979            );
980        }
981
982        // Asymptotic limits:
983        // Deep y-era (z << 6e4): J_y → 1, J_mu → 0
984        assert!(visibility_j_y(1e3) > 0.99, "J_y should → 1 at z=1e3");
985        assert!(visibility_j_mu(1e3) < 0.01, "J_mu should → 0 at z=1e3");
986
987        // Deep μ-era (z >> 6e4): J_mu → 1, J_y → 0
988        assert!(visibility_j_mu(1e6) > 0.99, "J_mu should → 1 at z=1e6");
989        assert!(visibility_j_y(1e6) < 0.01, "J_y should → 0 at z=1e6");
990
991        // Thermalization (z >> z_mu): J_bb* → 1
992        // At z=3e6, J_bb ≈ exp(-(3e6/2e6)^2.5) = exp(-5.8) ≈ 0.003
993        // so J_bb* ≈ 0.003 — deep thermalization suppresses the μ-distortion
994        assert!(
995            visibility_j_bb_star(3e6) < 0.1,
996            "J_bb* should be small at z=3e6 (deep thermalization)"
997        );
998        // At z << z_mu, J_bb* ≈ 1 (no thermalization)
999        assert!(
1000            visibility_j_bb_star(1e5) > 0.9,
1001            "J_bb* should → 1 below z_mu"
1002        );
1003
1004        // Monotonicity: J_mu increases with z, J_y decreases with z
1005        let zs = [1e3, 1e4, 5e4, 1e5, 5e5, 1e6];
1006        let jmu_vals: Vec<f64> = zs.iter().map(|&z| visibility_j_mu(z)).collect();
1007        let jy_vals: Vec<f64> = zs.iter().map(|&z| visibility_j_y(z)).collect();
1008        for i in 1..zs.len() {
1009            assert!(
1010                jmu_vals[i] >= jmu_vals[i - 1] - 1e-10,
1011                "J_mu should increase with z: J_mu({:.0e})={:.4} < J_mu({:.0e})={:.4}",
1012                zs[i],
1013                jmu_vals[i],
1014                zs[i - 1],
1015                jmu_vals[i - 1]
1016            );
1017            assert!(
1018                jy_vals[i] <= jy_vals[i - 1] + 1e-10,
1019                "J_y should decrease with z: J_y({:.0e})={:.4} > J_y({:.0e})={:.4}",
1020                zs[i],
1021                jy_vals[i],
1022                zs[i - 1],
1023                jy_vals[i - 1]
1024            );
1025        }
1026    }
1027
1028    #[test]
1029    fn test_y_from_heating() {
1030        // y-era burst: y ≈ Δρ/(4ρ)
1031        let z_h = 5.0e3;
1032        let drho = 1e-5;
1033        let sigma_z = 500.0;
1034        let dq_dz = |z: f64| -> f64 {
1035            drho * (-(z - z_h).powi(2) / (2.0 * sigma_z * sigma_z)).exp()
1036                / (2.0 * std::f64::consts::PI * sigma_z * sigma_z).sqrt()
1037        };
1038        let y = y_from_heating(dq_dz, 1e3, 1e5, 5000);
1039        let expected = 0.25 * drho;
1040        let rel_err = (y - expected).abs() / expected;
1041        assert!(
1042            rel_err < 0.05,
1043            "y = {y:.3e}, expected {expected:.3e}, err = {rel_err}"
1044        );
1045    }
1046
1047    #[test]
1048    fn test_mu_y_from_heating_consistency() {
1049        // mu_y_from_heating should give same result as separate calls
1050        let z_h = 2.0e5;
1051        let drho = 1e-5;
1052        let sigma_z = 5000.0;
1053        let dq_dz = |z: f64| -> f64 {
1054            drho * (-(z - z_h).powi(2) / (2.0 * sigma_z * sigma_z)).exp()
1055                / (2.0 * std::f64::consts::PI * sigma_z * sigma_z).sqrt()
1056        };
1057        let (mu_joint, y_joint) = mu_y_from_heating(&dq_dz, 1e3, 5e6, 10000);
1058        let mu_sep = mu_from_heating(&dq_dz, 1e3, 5e6, 10000);
1059        let y_sep = y_from_heating(&dq_dz, 1e3, 5e6, 10000);
1060
1061        assert!(
1062            (mu_joint - mu_sep).abs() / mu_sep.abs().max(1e-30) < 1e-10,
1063            "mu_joint={mu_joint:.4e} vs mu_sep={mu_sep:.4e}"
1064        );
1065        assert!(
1066            (y_joint - y_sep).abs() / y_sep.abs().max(1e-30) < 1e-10,
1067            "y_joint={y_joint:.4e} vs y_sep={y_sep:.4e}"
1068        );
1069    }
1070
1071    #[test]
1072    fn test_distortion_from_heating_spectrum() {
1073        let x_grid: Vec<f64> = (1..20).map(|i| i as f64).collect();
1074        let z_h = 2.0e5;
1075        let drho = 1e-5;
1076        let sigma_z = 5000.0;
1077        let dq_dz = |z: f64| -> f64 {
1078            drho * (-(z - z_h).powi(2) / (2.0 * sigma_z * sigma_z)).exp()
1079                / (2.0 * std::f64::consts::PI * sigma_z * sigma_z).sqrt()
1080        };
1081        let dn = distortion_from_heating(&x_grid, dq_dz, 1e3, 5e6, 5000);
1082        assert_eq!(dn.len(), x_grid.len());
1083
1084        // Cross-validate: decompose the spectrum and compare to mu_from_heating
1085        let mu_direct = mu_from_heating(&dq_dz, 1e3, 5e6, 5000);
1086
1087        // Energy integral: Δρ/ρ = ∫x³ Δn dx / G₃
1088        let drho_spectrum: f64 = (1..x_grid.len())
1089            .map(|i| {
1090                let dx = x_grid[i] - x_grid[i - 1];
1091                0.5 * (x_grid[i].powi(3) * dn[i] + x_grid[i - 1].powi(3) * dn[i - 1]) * dx
1092            })
1093            .sum::<f64>()
1094            / crate::constants::G3_PLANCK;
1095
1096        // At z=2e5 (μ-era), injected energy should appear in the spectrum
1097        assert!(
1098            drho_spectrum > 0.5 * drho && drho_spectrum < 2.0 * drho,
1099            "Spectrum energy should be ≈ Δρ/ρ = {drho:.1e}: got {drho_spectrum:.4e}"
1100        );
1101
1102        // μ from direct GF calculation should be positive and consistent
1103        assert!(
1104            mu_direct > 0.0,
1105            "μ should be positive for heating: {mu_direct:.4e}"
1106        );
1107        // μ ≈ 1.401 × Δρ/ρ in the μ-era
1108        assert!(
1109            mu_direct > 0.5 * 1.401 * drho && mu_direct < 2.0 * 1.401 * drho,
1110            "μ should be ≈ 1.401 × Δρ/ρ: got {mu_direct:.4e}, expected ≈ {:.4e}",
1111            1.401 * drho
1112        );
1113    }
1114
1115    #[test]
1116    fn test_x_c_physics_properties() {
1117        // x_c(z) defines the critical frequency below which DC/BR are efficient
1118        // at creating/absorbing photons. Physical properties:
1119
1120        // 1. x_c should be positive at all relevant redshifts
1121        for &z in &[1e3, 1e4, 1e5, 5e5, 1e6] {
1122            let xc = x_c(z);
1123            assert!(
1124                xc > 0.0 && xc.is_finite(),
1125                "x_c({z:.0e}) = {xc} should be positive finite"
1126            );
1127        }
1128
1129        // 2. x_c should be small (< 1) — DC/BR only dominate at low frequencies
1130        for &z in &[1e4, 1e5, 1e6] {
1131            let xc = x_c(z);
1132            assert!(
1133                xc < 1.0,
1134                "x_c({z:.0e}) = {xc:.4} should be < 1 (DC/BR only dominate at low x)"
1135            );
1136        }
1137
1138        // 3. Quadrature addition: x_c >= max(x_c_dc, x_c_br)
1139        for &z in &[1e4, 1e5, 1e6] {
1140            let xc = x_c(z);
1141            let xc_dc = x_c_dc(z);
1142            let xc_br = x_c_br(z);
1143            assert!(
1144                xc >= xc_dc.max(xc_br) - 1e-15,
1145                "x_c should >= max(x_c_dc, x_c_br) at z={z:.0e}"
1146            );
1147        }
1148
1149        // 4. Monotonicity: x_c should change smoothly with z
1150        let zs = [1e4, 5e4, 1e5, 5e5, 1e6];
1151        let xcs: Vec<f64> = zs.iter().map(|&z| x_c(z)).collect();
1152        for i in 1..xcs.len() {
1153            // Ratios between adjacent z should be bounded (no wild jumps)
1154            let ratio = xcs[i] / xcs[i - 1];
1155            assert!(
1156                ratio > 0.001 && ratio < 1000.0,
1157                "x_c ratio between z={:.0e} and z={:.0e} is {ratio:.4} (too extreme)",
1158                zs[i - 1],
1159                zs[i]
1160            );
1161        }
1162    }
1163
1164    #[test]
1165    fn test_distortion_from_photon_injection_spectrum() {
1166        let x_grid: Vec<f64> = (1..30).map(|i| 0.5 * i as f64).collect();
1167        let x_inj = 5.0;
1168        let z_h = 2.0e5;
1169        let sigma_z = 5000.0;
1170        let dn_over_n = 1e-5;
1171        let dn_dz = |z: f64| -> f64 {
1172            dn_over_n * (-(z - z_h).powi(2) / (2.0 * sigma_z * sigma_z)).exp()
1173                / (2.0 * std::f64::consts::PI * sigma_z * sigma_z).sqrt()
1174        };
1175        let cosmo = Cosmology::default();
1176        let delta_n = distortion_from_photon_injection(
1177            &x_grid,
1178            x_inj,
1179            dn_dz,
1180            PHOTON_GF_MU_ERA_Z_MIN,
1181            5e6,
1182            5000,
1183            0.5,
1184            &cosmo,
1185        );
1186        assert_eq!(delta_n.len(), x_grid.len());
1187
1188        // At x_inj=5.0, photon survives (P_s ≈ 1) so there should be a bump near x_inj.
1189        // Find the index closest to x_inj and verify it's a local maximum.
1190        let idx_inj = x_grid
1191            .iter()
1192            .enumerate()
1193            .min_by(|(_, a), (_, b)| {
1194                ((**a) - x_inj)
1195                    .abs()
1196                    .partial_cmp(&((**b) - x_inj).abs())
1197                    .unwrap()
1198            })
1199            .unwrap()
1200            .0;
1201        let dn_at_inj = delta_n[idx_inj];
1202        assert!(
1203            dn_at_inj > 0.0,
1204            "Δn should be positive near x_inj={x_inj}: got {dn_at_inj:.4e}"
1205        );
1206
1207        // The spectrum should have nonzero amplitude (thermalized + surviving bump)
1208        let max_dn = delta_n
1209            .iter()
1210            .map(|v| v.abs())
1211            .fold(0.0_f64, |a, b| a.max(b));
1212        assert!(
1213            max_dn > 1e-8,
1214            "Photon injection should produce nonzero distortion: max|dn|={max_dn:.4e}"
1215        );
1216
1217        // μ should be negative for x_inj=5 > x₀≈3.6 (excess photons above chemical
1218        // potential zero-crossing add energy but remove chemical potential)
1219        // Actually x_inj=5 > x₀ → positive μ (energy injection like behavior)
1220        let mu = mu_from_photon_injection(x_inj, z_h, dn_over_n);
1221        assert!(
1222            mu > 0.0,
1223            "μ should be positive for x_inj={x_inj} > x₀: got {mu:.4e}"
1224        );
1225    }
1226
1227    #[test]
1228    fn test_heating_rate_per_redshift_sign_convention() {
1229        // heating_rate_per_redshift = -heating_rate / (H(z)(1+z))
1230        // For positive heating (energy injection), heating_rate > 0 and
1231        // heating_rate_per_redshift < 0 (energy enters as z decreases).
1232        // The GF routines expect a POSITIVE dq/dz for heating, so callers
1233        // must negate or take abs.
1234        let cosmo = Cosmology::default();
1235        let z = 2.0e5;
1236
1237        let burst = crate::energy_injection::InjectionScenario::SingleBurst {
1238            z_h: z,
1239            delta_rho_over_rho: 1e-5,
1240            sigma_z: 100.0,
1241        };
1242
1243        let rate = burst.heating_rate(z, &cosmo);
1244        let rate_per_z = burst.heating_rate_per_redshift(z, &cosmo);
1245
1246        // heating_rate should be positive (energy injection)
1247        assert!(rate > 0.0, "heating_rate should be positive: {rate:.4e}");
1248        // heating_rate_per_redshift should be negative (dz < 0 direction)
1249        assert!(
1250            rate_per_z < 0.0,
1251            "heating_rate_per_redshift should be negative: {rate_per_z:.4e}"
1252        );
1253        // Their relationship: rate_per_z = -rate / (H(z)*(1+z))
1254        let expected = -rate / (cosmo.hubble(z) * (1.0 + z));
1255        assert!(
1256            (rate_per_z - expected).abs() / expected.abs() < 1e-10,
1257            "Sign convention: rate_per_z={rate_per_z:.4e}, expected={expected:.4e}"
1258        );
1259
1260        // Verify that mu_from_heating with POSITIVE dq/dz gives positive μ
1261        let mu = mu_from_heating(
1262            |zz: f64| {
1263                1e-5 * (-(zz - z).powi(2) / (2.0 * 5000.0_f64.powi(2))).exp()
1264                    / (2.0 * std::f64::consts::PI * 5000.0_f64.powi(2)).sqrt()
1265            },
1266            1e3,
1267            5e6,
1268            10000,
1269        );
1270        assert!(
1271            mu > 0.0,
1272            "Positive dq/dz (heating) must give positive μ: {mu:.4e}"
1273        );
1274    }
1275
1276    /// Verify visibility function values at specific redshifts against
1277    /// hand-computed values from the fitting formulas.
1278    ///
1279    /// This catches mis-transcribed coefficients in the visibility functions.
1280    /// Parameters: J_μ, J_y from Chluba (2013) arXiv:1304.6120, Eq. 5;
1281    /// J_bb* from Chluba (2015) arXiv:1506.06582, Eq. 13.
1282    #[test]
1283    fn test_visibility_spot_checks_transition_region() {
1284        // --- z = 1e5: transition region, most sensitive to coefficient errors ---
1285        let z = 1.0e5;
1286
1287        // Chluba 2013 Eq. 5: J_mu(z) = 1 - exp(-((1+z)/5.8e4)^1.88)
1288        let arg_mu = ((1.0 + z) / 5.8e4_f64).powf(1.88);
1289        let j_mu_hand = 1.0 - (-arg_mu).exp();
1290        let j_mu_code = visibility_j_mu(z);
1291        eprintln!("z=1e5: J_mu: code={j_mu_code:.4}, hand={j_mu_hand:.4}");
1292        assert!(
1293            (j_mu_code - j_mu_hand).abs() < 1e-10,
1294            "J_mu(1e5): code={j_mu_code}, hand={j_mu_hand}"
1295        );
1296
1297        // Chluba 2013 Eq. 5: J_y(z) = 1/(1 + ((1+z)/6.0e4)^2.58)
1298        let arg_y = ((1.0 + z) / 6.0e4_f64).powf(2.58);
1299        let j_y_hand = 1.0 / (1.0 + arg_y);
1300        let j_y_code = visibility_j_y(z);
1301        eprintln!("z=1e5: J_y: code={j_y_code:.4}, hand={j_y_hand:.4}");
1302        assert!(
1303            (j_y_code - j_y_hand).abs() < 1e-10,
1304            "J_y(1e5): code={j_y_code}, hand={j_y_hand}"
1305        );
1306
1307        // Chluba 2015 Eq. 13: J_bb*(z) = 0.983 * exp(-(z/Z_MU)^2.5) * (1 - 0.0381*(z/Z_MU)^2.29)
1308        let ratio_mu = z / Z_MU;
1309        let j_bb_hand = 0.983 * (-ratio_mu.powf(2.5)).exp() * (1.0 - 0.0381 * ratio_mu.powf(2.29));
1310        let j_bb_code = visibility_j_bb_star(z);
1311        eprintln!("z=1e5: J_bb*: code={j_bb_code:.6}, hand={j_bb_hand:.6}");
1312        assert!(
1313            (j_bb_code - j_bb_hand.max(0.0)).abs() < 1e-10,
1314            "J_bb*(1e5): code={j_bb_code}, hand={j_bb_hand}"
1315        );
1316
1317        // Physical sanity at z=1e5:
1318        // - mu-era is partially engaged: J_mu should be ~0.9
1319        // - y-era branching is small: J_y should be ~0.2
1320        // - Thermalization is negligible: J_bb* should be ~0.99
1321        assert!(
1322            j_mu_code > 0.85 && j_mu_code < 0.99,
1323            "J_mu(1e5) = {j_mu_code} outside expected [0.85, 0.99]"
1324        );
1325        assert!(
1326            j_y_code > 0.10 && j_y_code < 0.35,
1327            "J_y(1e5) = {j_y_code} outside expected [0.10, 0.35]"
1328        );
1329        assert!(
1330            j_bb_code > 0.98 && j_bb_code < 1.0,
1331            "J_bb*(1e5) = {j_bb_code} outside expected [0.98, 1.0]"
1332        );
1333
1334        // --- z = 5e4: deep in the transition region ---
1335        let z2 = 5.0e4;
1336        let j_mu_5e4 = visibility_j_mu(z2);
1337        let j_y_5e4 = visibility_j_y(z2);
1338        let j_bb_5e4 = visibility_j_bb_star(z2);
1339        eprintln!("z=5e4: J_mu={j_mu_5e4:.4}, J_y={j_y_5e4:.4}, J_bb*={j_bb_5e4:.6}");
1340
1341        // At z=5e4, both J_mu and J_y should be intermediate (transition region)
1342        assert!(
1343            j_mu_5e4 > 0.40 && j_mu_5e4 < 0.80,
1344            "J_mu(5e4) = {j_mu_5e4} outside transition range [0.40, 0.80]"
1345        );
1346        assert!(
1347            j_y_5e4 > 0.35 && j_y_5e4 < 0.70,
1348            "J_y(5e4) = {j_y_5e4} outside transition range [0.35, 0.70]"
1349        );
1350        // No catastrophic double-counting: J_mu + J_y should not exceed ~1.3
1351        assert!(
1352            j_mu_5e4 + j_y_5e4 < 1.5,
1353            "J_mu + J_y = {} > 1.5 at z=5e4 (double-counting)",
1354            j_mu_5e4 + j_y_5e4
1355        );
1356    }
1357}