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}