spectroxide/
bremsstrahlung.rs

1//! Bremsstrahlung (free-free) emission and absorption.
2//!
3//! Photon-number-changing process: e + ion → e + ion + γ.
4//! Dominates over double Compton at lower redshifts (z < few × 10⁵).
5//!
6//! The BR emission coefficient:
7//!   K_BR(x, θ_e) = (α λ_e³ / (2π√(6π))) θ_e^{-7/2} e^{-xφ}/φ³ Σ_i Z_i² N_i g_ff(Z_i, x, θ_e)
8//!
9//! In the code, we express this as a rate per Thomson scattering time τ:
10//!   K_BR(x) = prefactor × Σ_i Z_i² (N_i/N_e) g_ff(Z_i, x, θ_e)
11//!
12//! References:
13//! - Karzas & Latter (1961) — original Gaunt factors
14//! - Itoh et al. (2000) — relativistic thermal BR fits
15//! - Chluba & Sunyaev (2012), MNRAS 419, 1294 [Eq. 14]
16//! - Chluba, Ravenni & Bolliet (2020), MNRAS 492, 177 (BRpack)
17
18use crate::constants::*;
19use crate::spectrum::planck;
20
21/// Precomputed constant: α λ_e³ / (2π √(6π))
22/// This is the BR coefficient prefactor that is independent of x, θ_e, and species.
23const BR_PREFACTOR: f64 = ALPHA_FS * LAMBDA_ELECTRON * LAMBDA_ELECTRON * LAMBDA_ELECTRON
24    / (2.0 * std::f64::consts::PI * 4.341_607_527_349_605_5);
25// 4.341607527... = sqrt(6π). Hardcoded since sqrt is not const fn.
26
27/// Precomputed √3/π for Gaunt factor formula.
28const S3_PI: f64 = 0.5513_2889_5421_7921;
29
30/// Precomputed ln(2.25) for Z=1 Gaunt factor: ln(2.25/x) = LN_2_25 - ln(x)
31const LN_2_25: f64 = 0.8109_3021_6216_3288;
32
33/// Precomputed ln(2.25/2) = ln(1.125) for Z=2 Gaunt factor
34const LN_1_125: f64 = 0.1177_8303_5656_3834;
35
36/// Non-relativistic thermally-averaged free-free Gaunt factor.
37///
38/// Uses a softplus interpolation that smoothly approaches the classical Born
39/// limit at low frequencies while remaining ≥ 1 at high frequencies:
40///   g_ff = 1 + softplus((√3/π)(ln(2.25/(x Z)) + 0.5 ln(θ_e)) + 1.425)
41///
42/// The argument uses Z (nuclear charge, linear) not Z², following the
43/// Coulomb parameter convention where η_Z = Z e²/(ℏv) enters linearly
44/// inside the logarithm. The 0.5×ln(θ_e) is an empirical interpolation
45/// between the Born and classical regimes.
46///
47/// This approximation is from CosmoTherm (private communication, J. Chluba),
48/// calibrated against the exact Karzas & Latter (1961) Gaunt factor tabulations
49/// and the BRpack library (Chluba, Ravenni & Bolliet 2020, MNRAS 492, 177).
50/// The offset constant 1.425 improves agreement in the transition region.
51pub fn gaunt_ff_nr(x: f64, theta_e: f64, z_charge: f64) -> f64 {
52    if theta_e < 1e-30 {
53        return 1.0;
54    }
55    gaunt_ff_nr_fast(x, z_charge, 0.5 * theta_e.ln())
56}
57
58/// Fast Gaunt factor with precomputed 0.5*ln(θ_e) hoisted out of grid loop.
59#[inline]
60fn gaunt_ff_nr_fast(x: f64, z_charge: f64, half_ln_theta_e: f64) -> f64 {
61    if x < 1e-30 {
62        return 1.0;
63    }
64    let arg = S3_PI * ((2.25 / (x * z_charge)).ln() + half_ln_theta_e) + 1.425;
65    1.0 + softplus(arg)
66}
67
68/// Fast Gaunt factor with precomputed ln(x) and 0.5*ln(θ_e).
69///
70/// Avoids the ln() call inside the grid loop by using precomputed ln_x.
71/// `ln(2.25/(x*Z))` = `ln(2.25/Z) - ln(x)`.
72#[inline]
73fn gaunt_ff_nr_fast_preln(ln_x: f64, z_charge: f64, half_ln_theta_e: f64) -> f64 {
74    // Guard against x < 1e-30 (ln_x < -69.0) to match gaunt_ff_nr_fast and prevent Inf
75    if ln_x < -69.0 {
76        return 1.0;
77    }
78    let ln_2_25_over_z = if z_charge < 1.5 { LN_2_25 } else { LN_1_125 };
79    let arg = S3_PI * (ln_2_25_over_z - ln_x + half_ln_theta_e) + 1.425;
80    1.0 + softplus(arg)
81}
82
83/// Softplus function with asymptotic shortcuts.
84///
85/// softplus(x) = ln(1 + exp(x))
86/// - If x > 20: softplus ≈ x (error < 2e-9)
87/// - If x < -20: softplus ≈ exp(x) (error < 2e-9)
88/// Saves exp+ln calls for most grid points in asymptotic regimes.
89#[inline]
90fn softplus(arg: f64) -> f64 {
91    if arg > 20.0 {
92        arg
93    } else if arg < -20.0 {
94        arg.exp()
95    } else {
96        (1.0 + arg.exp()).ln()
97    }
98}
99
100/// BR emission coefficient K_BR at frequency x.
101///
102/// K_BR(x) = (α λ_e³/(2π√(6π))) θ_e^{-7/2} × (e^{-xφ}/φ³) × Σ_i Z_i² N_i g_ff
103///
104/// This is the coefficient such that:
105///   dn/dτ|_BR = (K_BR / x³) [n_eq − n]
106///
107/// where n_eq is the Planck distribution at T_e.
108/// The rate per unit volume is ∝ N_e × N_i (two-body process). Converting
109/// to per Thomson time (÷ N_e σ_T c) cancels one N_e, leaving N_i × λ_e³
110/// (dimensionless) in the coefficient.
111///
112/// # Arguments
113/// * `x` - dimensionless frequency hν/(kT_z)
114/// * `theta_e` - electron temperature kT_e/(m_e c²)
115/// * `theta_z` - reference temperature kT_z/(m_e c²)
116/// * `n_h` - hydrogen number density [1/m³]
117/// * `n_he` - helium number density [1/m³]
118/// * `n_e` - electron number density [1/m³]
119/// * `x_e_frac` - ionization fraction X_e = N_e/N_H
120pub fn br_emission_coefficient(
121    x: f64,
122    theta_e: f64,
123    theta_z: f64,
124    n_h: f64,
125    n_he: f64,
126    n_e: f64,
127    x_e_frac: f64,
128    cosmo: &crate::cosmology::Cosmology,
129) -> f64 {
130    if theta_e < 1e-30 || n_e < 1e-30 {
131        return 0.0;
132    }
133
134    let phi = theta_z / theta_e;
135
136    // Temperature factor: θ_e^{-7/2} × e^{-xφ} / φ³
137    let temp_factor = theta_e.powf(-3.5) * (-x * phi).exp() / phi.powi(3);
138
139    // Species sum: Σ_i Z_i² (N_i) g_ff(Z_i, x, θ_e)
140    // H⁺: Z=1, He²⁺: Z=2, He⁺: Z=1
141
142    // H⁺ and He⁺ both have Z=1, so their Gaunt factors are identical
143    let g_z1 = gaunt_ff_nr(x, theta_e, 1.0);
144    let g_z2 = gaunt_ff_nr(x, theta_e, 2.0);
145
146    // He ionization from Saha equations (via the temperature → redshift mapping).
147    //
148    // NOTE: Saha overestimates the speed of helium recombination at z ≲ 2000,
149    // where non-equilibrium delays (Peebles-like) become important. Because
150    // Saha drives n_HeII → 0 rapidly below ~2000 while the true residual HeII
151    // fraction decays more slowly, this under-counts HeII's contribution to
152    // BR at post-HeII-recombination redshifts. The effect is bounded at
153    // ~4% of the BR species_sum at z ~ 10³ (where the Saha approximation is
154    // worst) and is negligible for thermal-distortion accuracy. If sub-percent
155    // BR in the post-recombination tail is ever required, wire in the full
156    // recombination::x_e treatment for helium.
157    // Derive z from T_rad using the caller's cosmology T_CMB, not the hardcoded
158    // default. Otherwise a non-default cosmo.t_cmb shifts the Saha ionization
159    // curve in temperature by the ratio (cosmo.t_cmb / T_CMB_0).
160    let t_rad = theta_z * M_E_C2 / K_BOLTZMANN;
161    let z_approx = (t_rad / cosmo.t_cmb - 1.0).max(0.0);
162
163    let y_he_ii = crate::recombination::saha_he_ii(z_approx, cosmo);
164    let y_he_i = crate::recombination::saha_he_i(z_approx, cosmo);
165
166    let n_hii = x_e_frac.min(1.0) * n_h;
167    let n_heiii = y_he_ii * n_he;
168    let n_heii = (y_he_i - y_he_ii).max(0.0) * n_he;
169
170    let species_sum = n_hii * g_z1 + 4.0 * n_heiii * g_z2 + n_heii * g_z1;
171
172    BR_PREFACTOR * temp_factor * species_sum
173}
174
175/// BR emission coefficient with pre-computed He ionization fractions.
176///
177/// Same physics as `br_emission_coefficient` but avoids redundant Saha
178/// evaluations when called in a grid loop (z_approx is identical for all x).
179pub fn br_emission_coefficient_with_he(
180    x: f64,
181    theta_e: f64,
182    theta_z: f64,
183    n_h: f64,
184    n_he: f64,
185    n_e: f64,
186    x_e_frac: f64,
187    y_he_ii: f64,
188    y_he_i: f64,
189) -> f64 {
190    if theta_e < 1e-30 || n_e < 1e-30 {
191        return 0.0;
192    }
193
194    let phi = theta_z / theta_e;
195    let temp_factor = theta_e.powf(-3.5) * (-x * phi).exp() / phi.powi(3);
196
197    // H⁺ and He⁺ both have Z=1, so their Gaunt factors are identical
198    let g_z1 = gaunt_ff_nr(x, theta_e, 1.0);
199    let g_he2 = gaunt_ff_nr(x, theta_e, 2.0);
200
201    let n_hii = x_e_frac.min(1.0) * n_h;
202    let n_heiii = y_he_ii * n_he;
203    let n_heii = (y_he_i - y_he_ii).max(0.0) * n_he;
204
205    let species_sum = n_hii * g_z1 + 4.0 * n_heiii * g_he2 + n_heii * g_z1;
206
207    BR_PREFACTOR * temp_factor * species_sum
208}
209
210/// Precomputed x-independent BR factors for use in the grid loop.
211///
212/// Call `br_precompute` once per timestep, then `br_emission_coefficient_fast`
213/// for each grid point. This hoists θ_e^{-7/2}/φ³ and species densities
214/// out of the inner loop.
215pub struct BrPrecomputed {
216    /// BR_PREFACTOR × θ_e^{-7/2} / φ³
217    pub base_factor: f64,
218    /// φ = θ_z / θ_e (for the exp(-xφ) x-dependent part)
219    pub phi: f64,
220    pub n_hii: f64,
221    pub n_heiii: f64,
222    pub n_heii: f64,
223    /// 0.5 * ln(θ_e), precomputed for fast Gaunt factor evaluation
224    pub half_ln_theta_e: f64,
225}
226
227/// Precompute x-independent BR factors.
228pub fn br_precompute(
229    theta_e: f64,
230    theta_z: f64,
231    n_h: f64,
232    n_he: f64,
233    n_e: f64,
234    x_e_frac: f64,
235    y_he_ii: f64,
236    y_he_i: f64,
237) -> Option<BrPrecomputed> {
238    if theta_e < 1e-30 || n_e < 1e-30 {
239        return None;
240    }
241    let phi = theta_z / theta_e;
242    let base_factor = BR_PREFACTOR * theta_e.powf(-3.5) / (phi * phi * phi);
243    Some(BrPrecomputed {
244        base_factor,
245        phi,
246        n_hii: x_e_frac.min(1.0) * n_h,
247        n_heiii: y_he_ii * n_he,
248        n_heii: (y_he_i - y_he_ii).max(0.0) * n_he,
249        half_ln_theta_e: 0.5 * theta_e.ln(),
250    })
251}
252
253/// Fast BR emission coefficient using precomputed x-independent factors.
254///
255/// Only computes the x-dependent parts: exp(-xφ) and Gaunt factors.
256#[inline]
257pub fn br_emission_coefficient_fast(x: f64, pre: &BrPrecomputed) -> f64 {
258    let exp_xphi = (-x * pre.phi).exp();
259    // H⁺ and He⁺ both have Z=1, so their Gaunt factors are identical
260    let g_z1 = gaunt_ff_nr_fast(x, 1.0, pre.half_ln_theta_e);
261    let g_he2 = gaunt_ff_nr_fast(x, 2.0, pre.half_ln_theta_e);
262
263    let species_sum = pre.n_hii * g_z1 + 4.0 * pre.n_heiii * g_he2 + pre.n_heii * g_z1;
264
265    pre.base_factor * exp_xphi * species_sum
266}
267
268/// Fast BR emission coefficient with precomputed ln(x).
269///
270/// Same as `br_emission_coefficient_fast` but avoids ln() calls in the
271/// Gaunt factor by using a precomputed ln(x) value.
272#[inline]
273pub fn br_emission_coefficient_fast_preln(x: f64, ln_x: f64, pre: &BrPrecomputed) -> f64 {
274    let exp_xphi = (-x * pre.phi).exp();
275    let g_z1 = gaunt_ff_nr_fast_preln(ln_x, 1.0, pre.half_ln_theta_e);
276    let g_he2 = gaunt_ff_nr_fast_preln(ln_x, 2.0, pre.half_ln_theta_e);
277
278    let species_sum = pre.n_hii * g_z1 + 4.0 * pre.n_heiii * g_he2 + pre.n_heii * g_z1;
279
280    pre.base_factor * exp_xphi * species_sum
281}
282
283/// BR emission rate integrated over frequency (for electron temperature equation).
284///
285/// H_BR = (1/(4 G₃ θ_z)) ∫ [1 − n(e^{x_e} − 1)] K_BR(x)/x³ · x³ dx
286///      = (1/(4 G₃ θ_z)) ∫ [1 − n(e^{x_e} − 1)] K_BR(x) dx
287///
288/// This enters the electron temperature evolution as a cooling term,
289/// analogous to dc_heating_integral() in double_compton.rs.
290pub fn br_heating_integral(
291    x_grid: &[f64],
292    delta_n: &[f64],
293    theta_z: f64,
294    theta_e: f64,
295    n_h: f64,
296    n_he: f64,
297    n_e: f64,
298    x_e_frac: f64,
299    y_he_ii: f64,
300    y_he_i: f64,
301) -> f64 {
302    if theta_e < 1e-30 || n_e < 1e-30 {
303        return 0.0;
304    }
305    let phi = theta_z / theta_e;
306    let mut integral = 0.0;
307
308    for i in 1..x_grid.len() {
309        let dx = x_grid[i] - x_grid[i - 1];
310        let x_mid = 0.5 * (x_grid[i] + x_grid[i - 1]);
311        let dn_mid = 0.5 * (delta_n[i] + delta_n[i - 1]);
312        let n_mid = planck(x_mid) + dn_mid;
313        let x_e = x_mid * phi;
314
315        let k_br = br_emission_coefficient_with_he(
316            x_mid, theta_e, theta_z, n_h, n_he, n_e, x_e_frac, y_he_ii, y_he_i,
317        );
318        // Integrand: [1 - n (e^{x_e} - 1)] K_BR dx
319        let factor = 1.0 - n_mid * x_e.exp_m1();
320        integral += factor * k_br * dx;
321    }
322
323    integral / (4.0 * crate::constants::G3_PLANCK * theta_z)
324}
325
326/// Compute the BR contribution to the photon equation RHS (test-only).
327///
328/// Production code uses the coupled inplace solver with precomputed rates.
329///
330/// **Do not promote this to production.** Same near-cancellation hazard as
331/// `double_compton::dc_rhs`: the naive `(e^{x_e} − 1)` form loses precision
332/// near `ρ_e = 1`. The production path (`solver.rs::compute_emission_rates`)
333/// uses the analytical Taylor expansion when `|ρ_e − 1| < 0.01`.
334#[cfg(test)]
335pub fn br_rhs(
336    x_grid: &[f64],
337    delta_n: &[f64],
338    theta_z: f64,
339    theta_e: f64,
340    n_h: f64,
341    n_he: f64,
342    n_e: f64,
343    x_e_frac: f64,
344    cosmo: &crate::cosmology::Cosmology,
345) -> Vec<f64> {
346    let n = x_grid.len();
347    let phi = theta_z / theta_e;
348    let mut rhs = vec![0.0; n];
349
350    for i in 0..n {
351        let xi = x_grid[i];
352        let k_br = br_emission_coefficient(xi, theta_e, theta_z, n_h, n_he, n_e, x_e_frac, cosmo);
353        let x_e = xi * phi; // frequency at electron temperature
354
355        // Chluba & Sunyaev (2012) Eq. (8) form (matches dc_rhs):
356        //   dn/dτ|_BR = (K_BR/x³) [1 - n(e^{x_e} - 1)]
357        let n_current = planck(xi) + delta_n[i];
358        rhs[i] = k_br / xi.powi(3) * (1.0 - n_current * x_e.exp_m1());
359    }
360
361    rhs
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn test_gaunt_ff_positive_and_physical() {
370        assert!(gaunt_ff_nr(0.1, 1e-5, 1.0) > 0.0);
371        assert!(gaunt_ff_nr(1.0, 1e-4, 1.0) > 0.0);
372        assert!(gaunt_ff_nr(0.001, 1e-6, 1.0) > 0.0);
373        // Z=2 (He²⁺) should also be positive
374        assert!(gaunt_ff_nr(0.1, 1e-5, 2.0) > 0.0);
375        // Z=2 Gaunt factor differs from Z=1 due to ln(2.25/(x*Z))
376        let g1 = gaunt_ff_nr(1.0, 1e-5, 1.0);
377        let g2 = gaunt_ff_nr(1.0, 1e-5, 2.0);
378        assert!(g1 != g2, "Z=1 and Z=2 Gaunt factors should differ");
379
380        // Physical check: Gaunt factor should be >= 1.0 for all inputs
381        // (the softplus function adds >= 0 to 1.0 base)
382        for &x in &[0.001, 0.01, 0.1, 1.0, 10.0] {
383            for &theta in &[1e-6, 1e-5, 1e-4, 1e-3] {
384                let g = gaunt_ff_nr(x, theta, 1.0);
385                assert!(g >= 1.0, "g_ff(x={x}, θ={theta}) = {g} < 1.0");
386            }
387        }
388
389        // At very low x (RJ regime), Gaunt factor should be large (~5-15)
390        // Born approximation: g ≈ (√3/π) ln(2.25 θ_e / x) for small x
391        let g_lowx = gaunt_ff_nr(1e-4, 1e-4, 1.0);
392        assert!(
393            g_lowx > 3.0,
394            "Gaunt factor at very low x should be > 3 (classical limit): got {g_lowx:.2}"
395        );
396
397        // Gaunt factor should decrease with increasing x (less classical)
398        let g_highx = gaunt_ff_nr(1.0, 1e-4, 1.0);
399        assert!(
400            g_lowx > g_highx,
401            "Gaunt factor should decrease with x: g(1e-4)={g_lowx:.2}, g(1)={g_highx:.2}"
402        );
403    }
404
405    #[test]
406    fn test_br_coefficient_positive() {
407        let theta_e = 1e-5;
408        let theta_z = theta_e;
409        let n_h = 1e6; // dummy density
410        let n_he = 0.08 * n_h;
411        let n_e = n_h;
412        let x_e = 1.0;
413        let cosmo = crate::cosmology::Cosmology::default();
414
415        for &x in &[0.001, 0.01, 0.1, 1.0] {
416            let k = br_emission_coefficient(x, theta_e, theta_z, n_h, n_he, n_e, x_e, &cosmo);
417            assert!(k >= 0.0, "K_BR should be non-negative at x={x}: K={k}");
418        }
419    }
420
421    #[test]
422    fn test_br_rhs_zero_for_planck() {
423        let x: Vec<f64> = (1..100).map(|i| 0.01 * i as f64).collect();
424        let delta_n = vec![0.0; x.len()];
425        let theta = 1e-5;
426        let n_h = 1e6;
427        let n_he = 0.08 * n_h;
428        let n_e = n_h;
429        let cosmo = crate::cosmology::Cosmology::default();
430
431        let rhs = br_rhs(&x, &delta_n, theta, theta, n_h, n_he, n_e, 1.0, &cosmo);
432        let max_rhs: f64 = rhs
433            .iter()
434            .map(|v| v.abs())
435            .fold(0.0, |a, b| if b.is_nan() { f64::NAN } else { a.max(b) });
436        assert!(
437            max_rhs < 1e-20,
438            "BR RHS should be zero for Planck with T_e=T_z: max = {max_rhs}"
439        );
440    }
441
442    #[test]
443    fn test_br_heating_integral_zero_for_planck() {
444        // For Planck spectrum with T_e = T_z, BR heating integral should vanish
445        let x: Vec<f64> = (1..500).map(|i| 0.001 + 0.06 * i as f64).collect();
446        let delta_n = vec![0.0; x.len()];
447        let theta = 4.6e-4; // z ~ 1e6
448        let n_h = 1e12;
449        let n_he = 0.08 * n_h;
450        let n_e = n_h;
451
452        let h_br = br_heating_integral(&x, &delta_n, theta, theta, n_h, n_he, n_e, 1.0, 1.0, 1.0);
453        assert!(
454            h_br.abs() < 1e-10,
455            "BR heating integral should be ~0 for Planck: H_BR = {h_br}"
456        );
457    }
458
459    #[test]
460    fn test_br_rhs_nonzero_for_te_ne_tz() {
461        // When T_e > T_z, BR should drive photon creation at low x (positive RHS)
462        // and absorption at high x. The detailed balance factor [1 - n(e^{x_e}-1)]
463        // must be present to get the correct sign pattern.
464        let x: Vec<f64> = (1..100).map(|i| 0.01 * i as f64).collect();
465        let delta_n = vec![0.0; x.len()];
466        let theta_e = 1.001e-5; // T_e slightly above T_z
467        let theta_z = 1e-5;
468        let n_h = 1e6;
469        let n_he = 0.08 * n_h;
470        let n_e = n_h;
471        let cosmo = crate::cosmology::Cosmology::default();
472
473        let rhs = br_rhs(&x, &delta_n, theta_z, theta_e, n_h, n_he, n_e, 1.0, &cosmo);
474
475        // At low x, T_e > T_z means equilibrium has more photons → emission (positive)
476        assert!(
477            rhs[1] > 0.0,
478            "BR RHS should be positive at low x when T_e > T_z: rhs[1] = {}",
479            rhs[1]
480        );
481
482        // Verify consistency with dc_rhs form: both use [1 - n*(e^{x_e}-1)]
483        // so for Planck spectrum (delta_n=0), the sign at low x should match
484        let dc_rhs_vals = crate::double_compton::dc_rhs(&x, &delta_n, theta_z, theta_e);
485        assert!(
486            dc_rhs_vals[1] > 0.0,
487            "DC RHS should also be positive at low x when T_e > T_z"
488        );
489    }
490
491    #[test]
492    fn test_softplus_all_branches() {
493        // Normal branch: -20 < x < 20
494        let sp_0 = softplus(0.0);
495        assert!((sp_0 - (2.0_f64).ln()).abs() < 1e-14, "softplus(0) = ln(2)");
496
497        // High branch: x > 20
498        let sp_high = softplus(25.0);
499        assert!(
500            (sp_high - 25.0).abs() < 1e-8,
501            "softplus(25) ≈ 25: got {sp_high}"
502        );
503
504        // Low branch: x < -20
505        let sp_low = softplus(-25.0);
506        let expected = (-25.0_f64).exp();
507        assert!(
508            (sp_low - expected).abs() < 1e-20,
509            "softplus(-25) ≈ exp(-25)"
510        );
511    }
512
513    /// Verify all BR fast variants (fast, fast_preln, with_he) match the reference
514    /// br_emission_coefficient to machine precision across multiple parameter combos.
515    /// Consolidates: test_br_precompute_and_fast_consistency, test_br_with_he_matches_standard,
516    /// test_br_emission_fast_matches_full, test_br_emission_fast_preln_matches.
517    #[test]
518    fn test_br_fast_variants_match_reference() {
519        let cases: Vec<(f64, f64, f64, f64, f64, f64, f64)> = vec![
520            // (x, theta_e, theta_z, n_h, n_he, n_e, x_e_frac)
521            (0.001, 1e-5, 1e-5, 1e6, 8e4, 1e6, 1.0),
522            (0.01, 1e-5, 1e-5, 1e6, 8e4, 1e6, 1.0),
523            (0.1, 1e-4, 9e-5, 1e8, 8e6, 1.1e8, 1.1),
524            (1.0, 5e-6, 5e-6, 1e10, 8e8, 1e10, 1.0),
525            (10.0, 1e-3, 1e-3, 1e12, 8e10, 1e12, 1.0),
526        ];
527
528        let cosmo = crate::cosmology::Cosmology::default();
529
530        for &(x, theta_e, theta_z, n_h, n_he, n_e, x_e_frac) in &cases {
531            let y_he_ii = 0.9;
532            let y_he_i = 1.0;
533
534            // Reference: br_emission_coefficient_with_he
535            let k_ref = br_emission_coefficient_with_he(
536                x, theta_e, theta_z, n_h, n_he, n_e, x_e_frac, y_he_ii, y_he_i,
537            );
538
539            // Fast variant
540            let pre =
541                br_precompute(theta_e, theta_z, n_h, n_he, n_e, x_e_frac, y_he_ii, y_he_i).unwrap();
542            let k_fast = br_emission_coefficient_fast(x, &pre);
543            let rel_fast = (k_fast - k_ref).abs() / k_ref.max(1e-30);
544            assert!(
545                rel_fast < 1e-10,
546                "fast mismatch at x={x}: ref={k_ref}, fast={k_fast}, rel={rel_fast}"
547            );
548
549            // Fast with pre-computed ln(x)
550            let k_preln = br_emission_coefficient_fast_preln(x, x.ln(), &pre);
551            let rel_preln = (k_preln - k_ref).abs() / k_ref.max(1e-30);
552            assert!(
553                rel_preln < 1e-10,
554                "fast_preln mismatch at x={x}: ref={k_ref}, preln={k_preln}, rel={rel_preln}"
555            );
556        }
557
558        // Also verify br_emission_coefficient (with Saha) matches br_emission_coefficient_with_he
559        // at a specific point where we can get the same He ionization fractions
560        let theta_e = 1e-5;
561        let theta_z = theta_e;
562        let n_h = 1e6;
563        let n_he = 0.08 * n_h;
564        let n_e = n_h;
565        let x_e = 1.0;
566        let z_approx = (theta_z / crate::constants::theta_z(0.0) - 1.0).max(100.0);
567        let y_he_i = crate::recombination::saha_he_i(z_approx, &cosmo);
568        let y_he_ii = crate::recombination::saha_he_ii(z_approx, &cosmo);
569
570        let k_saha = br_emission_coefficient(0.1, theta_e, theta_z, n_h, n_he, n_e, x_e, &cosmo);
571        let k_he = br_emission_coefficient_with_he(
572            0.1, theta_e, theta_z, n_h, n_he, n_e, x_e, y_he_ii, y_he_i,
573        );
574        let rel = (k_he - k_saha).abs() / k_saha.max(1e-30);
575        assert!(
576            rel < 1e-10,
577            "with_he vs saha mismatch: saha={k_saha}, he={k_he}, rel={rel}"
578        );
579    }
580
581    #[test]
582    fn test_br_precompute_none_for_zero_theta() {
583        let pre = br_precompute(0.0, 1e-5, 1e6, 1e4, 1e6, 1.0, 0.0, 1.0);
584        assert!(pre.is_none(), "Should return None for theta_e = 0");
585    }
586
587    #[test]
588    fn test_br_precompute_returns_none_for_tiny_inputs() {
589        // theta_e < 1e-30
590        let pre = br_precompute(1e-31, 1e-5, 1e6, 8e4, 1e6, 1.0, 0.0, 1.0);
591        assert!(pre.is_none(), "Should return None for theta_e < 1e-30");
592
593        // n_e < 1e-30
594        let pre = br_precompute(1e-5, 1e-5, 1e6, 8e4, 1e-31, 1.0, 0.0, 1.0);
595        assert!(pre.is_none(), "Should return None for n_e < 1e-30");
596
597        // Both tiny
598        let pre = br_precompute(1e-31, 1e-5, 1e6, 8e4, 1e-31, 1.0, 0.0, 1.0);
599        assert!(pre.is_none(), "Should return None when both are tiny");
600    }
601
602    #[test]
603    fn test_gaunt_ff_nr_guards() {
604        // theta_e < 1e-30 → returns 1.0
605        assert_eq!(gaunt_ff_nr(0.1, 1e-31, 1.0), 1.0);
606        assert_eq!(gaunt_ff_nr(0.1, 0.0, 1.0), 1.0);
607
608        // x < 1e-30 → returns 1.0 (via gaunt_ff_nr_fast guard)
609        assert_eq!(gaunt_ff_nr(1e-31, 1e-5, 1.0), 1.0);
610        assert_eq!(gaunt_ff_nr(0.0, 1e-5, 1.0), 1.0);
611
612        // Normal inputs → returns > 1.0 (softplus adds >= 0 to 1.0)
613        let g = gaunt_ff_nr(0.1, 1e-5, 1.0);
614        assert!(g >= 1.0, "Gaunt factor should be >= 1.0, got {g}");
615    }
616
617    #[test]
618    fn test_br_hii_capped_at_nh() {
619        // When x_e_frac > 1 (includes helium electrons), n_hii should not exceed n_h
620        let theta_e = 1e-5;
621        let theta_z = theta_e;
622        let n_h = 1e6;
623        let n_he = 0.08 * n_h;
624        let n_e = 1.16 * n_h; // x_e > 1 due to helium
625        let x_e = 1.16; // total electron fraction including He
626        let cosmo = crate::cosmology::Cosmology::default();
627
628        let k = br_emission_coefficient(0.1, theta_e, theta_z, n_h, n_he, n_e, x_e, &cosmo);
629        // Should be finite and not blow up from overcounting
630        assert!(k.is_finite() && k >= 0.0, "K_BR should be finite: {k}");
631
632        // Compare with x_e = 1.0 — should be similar (H contribution capped)
633        let k_ref = br_emission_coefficient(0.1, theta_e, theta_z, n_h, n_he, n_e, 1.0, &cosmo);
634        // With x_e capped at 1.0 for H+ contribution, H+ part should be identical.
635        // Only He ionization fractions differ (x_e=1.16 vs 1.0 changes y_he_i/y_he_ii).
636        // Total should agree within 20% since H+ dominates.
637        assert!(
638            (k / k_ref.max(1e-30) - 1.0).abs() < 0.2,
639            "BR with x_e=1.16 should be within 20% of x_e=1.0: {k:.4e} vs {k_ref:.4e}"
640        );
641    }
642
643    #[test]
644    fn test_br_hardcoded_constants() {
645        // Verify hardcoded transcriptions against computed values.
646        // sqrt(6π)
647        let sqrt_6pi = (6.0 * std::f64::consts::PI).sqrt();
648        assert!(
649            (sqrt_6pi - 4.341_607_527_349_605_5).abs() < 1e-14,
650            "sqrt(6π) mismatch: {sqrt_6pi}"
651        );
652
653        // √3/π
654        let s3_pi_exact = 3.0_f64.sqrt() / std::f64::consts::PI;
655        assert!(
656            (S3_PI - s3_pi_exact).abs() < 1e-15,
657            "S3_PI mismatch: hardcoded={S3_PI}, computed={s3_pi_exact}"
658        );
659
660        // ln(2.25)
661        let ln_225_exact = 2.25_f64.ln();
662        assert!(
663            (LN_2_25 - ln_225_exact).abs() < 1e-15,
664            "LN_2_25 mismatch: hardcoded={LN_2_25}, computed={ln_225_exact}"
665        );
666
667        // ln(2.25/2) = ln(1.125)
668        let ln_1125_exact = 1.125_f64.ln();
669        assert!(
670            (LN_1_125 - ln_1125_exact).abs() < 1e-15,
671            "LN_1_125 mismatch: hardcoded={LN_1_125}, computed={ln_1125_exact}"
672        );
673
674        // BR_PREFACTOR = α λ_e³ / (2π √(6π))
675        let prefactor_exact =
676            ALPHA_FS * LAMBDA_ELECTRON.powi(3) / (2.0 * std::f64::consts::PI * sqrt_6pi);
677        assert!(
678            (BR_PREFACTOR - prefactor_exact).abs() / prefactor_exact < 1e-14,
679            "BR_PREFACTOR mismatch: hardcoded={BR_PREFACTOR}, computed={prefactor_exact}"
680        );
681    }
682
683    /// Verify K_BR has physically reasonable absolute magnitude at z=10^5.
684    ///
685    /// The historical /n_e bug made K_BR ~ 10^11 instead of O(1)–O(10^3).
686    /// This test catches that class of error by computing K_BR from first
687    /// principles and asserting the order of magnitude.
688    ///
689    /// Hand calculation at z=10^5, x=0.1, rho_e=1 (T_e=T_z):
690    ///   BR_PREFACTOR ≈ 6.1e-40 m³
691    ///   θ_z^{-7/2} ≈ (4.60e-5)^{-3.5} ≈ 2.1e16
692    ///   exp(-0.1) ≈ 0.90
693    ///   n_HII ≈ 1.9e14 m^{-3} (fully ionized H at z=10^5)
694    ///   g_ff ≈ 3
695    ///   K_BR ≈ 6.1e-40 × 2.1e16 × 0.90 × 1.9e14 × 3 ≈ 6.6e-9
696    #[test]
697    fn test_br_emission_coefficient_magnitude() {
698        let cosmo = crate::cosmology::Cosmology::default();
699        let z = 1.0e5;
700        let tz = crate::constants::theta_z(z);
701        let n_h = cosmo.n_h(z);
702        let n_he = cosmo.n_he(z);
703        let x_e = 1.0 + 2.0 * cosmo.f_he(); // fully ionized
704        let n_e = cosmo.n_e(z, x_e);
705
706        // At equilibrium: T_e = T_z, so theta_e = theta_z
707        let k_br = br_emission_coefficient(0.1, tz, tz, n_h, n_he, n_e, x_e, &cosmo);
708
709        eprintln!("K_BR(x=0.1, z=1e5) = {k_br:.4e}");
710        eprintln!("  BR_PREFACTOR = {BR_PREFACTOR:.4e}");
711        eprintln!("  theta_z = {tz:.4e}");
712        eprintln!("  n_H = {n_h:.4e}, n_He = {n_he:.4e}, n_e = {n_e:.4e}");
713
714        // Must be positive
715        assert!(k_br > 0.0, "K_BR must be positive, got {k_br}");
716
717        // Order-of-magnitude check: should be O(10^{-9}) to O(10^{-7}),
718        // NOT O(10^{11}) as the historical bug produced.
719        assert!(
720            k_br > 1e-12 && k_br < 1e-4,
721            "K_BR(x=0.1, z=1e5) = {k_br:.4e} is outside physically reasonable \
722             range [1e-12, 1e-4]. Historical /n_e bug gave ~10^11."
723        );
724
725        // Verify hand calculation to within 2 OOM
726        let hand_estimate = 6.6e-9;
727        let ratio = k_br / hand_estimate;
728        assert!(
729            ratio > 0.01 && ratio < 100.0,
730            "K_BR = {k_br:.4e} differs from hand estimate {hand_estimate:.4e} by {ratio:.1e}x"
731        );
732
733        // Also check at x=1 (less Boltzmann suppression of g_ff)
734        let k_br_x1 = br_emission_coefficient(1.0, tz, tz, n_h, n_he, n_e, x_e, &cosmo);
735        eprintln!("K_BR(x=1.0, z=1e5) = {k_br_x1:.4e}");
736        assert!(
737            k_br_x1 > 1e-12 && k_br_x1 < 1e-4,
738            "K_BR(x=1.0, z=1e5) = {k_br_x1:.4e} outside [1e-12, 1e-4]"
739        );
740
741        // x=0.1 should have more emission than x=1 (exponential suppression)
742        assert!(
743            k_br > k_br_x1,
744            "K_BR should decrease with x: K(0.1)={k_br:.4e} < K(1.0)={k_br_x1:.4e}"
745        );
746    }
747}