spectroxide/
recombination.rs

1//! Hydrogen and helium recombination history.
2//!
3//! Computes the free electron fraction X_e(z) needed for Thomson scattering
4//! rates and number densities throughout the spectral distortion era.
5//!
6//! ## Physical picture
7//!
8//! - z > 8000: Fully ionized (H + He). Helium is doubly ionized (He²⁺).
9//! - z ~ 6000: He²⁺ recombines to He⁺ (54.4 eV Saha).
10//! - z ~ 2000: He⁺ recombines to He (24.6 eV Saha).
11//! - z ~ 1500–800: Hydrogen recombines. Saha equilibrium breaks down
12//!   due to the Lyman-α bottleneck; the Peebles three-level atom (TLA)
13//!   captures the delayed freeze-out.
14//! - z < 200: Residual ionization freezes out at X_e ~ 2×10⁻⁴.
15//!
16//! ## Implementation
17//!
18//! Follows DarkHistory's three-level atom structure (Hongwan Liu et al. 2020):
19//! - `alpha_recomb`: Case-B recombination coefficient (Péquignot fit)
20//! - `beta_ion`: Photoionization rate from n=2
21//! - `peebles_c`: Peebles C factor decomposed into competing rates
22//! - Saha-subtracted ODE form to avoid catastrophic cancellation
23//!
24//! The fudge factor F=1.125 follows Chluba & Thomas (2011, arXiv:1011.3758),
25//! matching DarkHistory. This gives ~1% accuracy in X_e, sufficient for
26//! spectral distortion calculations.
27//!
28//! ## References
29//!
30//! - Peebles (1968) — Three-level atom model
31//! - Péquignot, Petitjean & Boisson (1991) — Case-B recombination fit
32//! - Seager, Sasselov & Scott (1999) — RECFAST
33//! - Chluba & Thomas (2011, arXiv:1011.3758) — Updated fudge factor
34//! - Liu et al. (2020, DarkHistory) — Reference implementation
35
36use crate::constants::*;
37use crate::cosmology::Cosmology;
38
39// --- Helium recombination (Saha equilibrium) ---
40
41/// Thermal de Broglie factor: (m_e k_B T / (2π ℏ²))^{3/2} [m⁻³].
42///
43/// This appears in every Saha equation as the density of states
44/// for a free electron.
45#[inline]
46fn thermal_de_broglie(t: f64) -> f64 {
47    (M_ELECTRON * K_BOLTZMANN * t / (2.0 * std::f64::consts::PI * HBAR * HBAR)).powf(1.5)
48}
49
50/// Solve the Saha quadratic X²/(1−X) = S for the ionized fraction X.
51///
52/// Handles extreme limits to avoid overflow/underflow. Used by the hydrogen
53/// Saha (where the self-ionization n_e = X·n_H is exact at z ≳ 1500 because
54/// H dominates the electron budget).
55#[inline]
56fn solve_saha_quadratic(s: f64) -> f64 {
57    if s > 1e10 {
58        1.0
59    } else if s < 1e-10 {
60        s.sqrt()
61    } else {
62        (-s + (s * s + 4.0 * s).sqrt()) / 2.0
63    }
64}
65
66/// Solve the linear Saha X/(1−X) = S for the ionized fraction X.
67#[inline]
68fn solve_saha_linear(s: f64) -> f64 {
69    if s > 1e15 {
70        1.0
71    } else if s < 1e-15 {
72        s
73    } else {
74        s / (1.0 + s)
75    }
76}
77
78/// He II → He I Saha ionization fraction (54.4 eV).
79///
80/// Returns the fraction of helium that is doubly ionized (He²⁺).
81/// Statistical weight ratio: g(He²⁺)g(e)/g(He⁺) = 1×2/2 = 1.
82/// He²⁺ is a bare alpha particle (spin-0 nucleus), g=1.
83/// He⁺ ground state (1s, hydrogen-like), g=2 (electron spin).
84/// Free electron, g=2 (spin).
85///
86/// Uses the standard (RECFAST/Seager+1999) total free-electron Saha form
87/// y / (1 − y) = K(T) / n_e, where n_e ≈ n_H + (1 + y_II)·n_He is dominated
88/// by H⁺ at z ≳ 1500 (H is fully ionized throughout He recombination since
89/// χ_I(H) = 13.6 eV ≪ χ_II(He) = 54.4 eV). Using n_e = n_H + 2·n_He
90/// (y_II = 1 limit) introduces ≲7% error in n_e vs the fully self-consistent
91/// y_II = 0 limit — negligible compared to the ~factor-of-29 error from the
92/// old He-only quadratic form that assumed n_e = y·n_He.
93pub fn saha_he_ii(z: f64, cosmo: &Cosmology) -> f64 {
94    let t = cosmo.t_cmb * (1.0 + z);
95    let n_he = cosmo.n_he(z);
96    if n_he < 1e-30 {
97        return 1.0;
98    }
99
100    // n_e dominated by fully-ionized hydrogen at z where χ_II(He) matters.
101    // Include the He²⁺ contribution at the y=1 limit; this overestimates n_e
102    // by at most f_He/(1+f_He) ≈ 7% during the transition.
103    let n_e = cosmo.n_h(z) + 2.0 * n_he;
104
105    let e_ion = E_HE_II_ION_EV * EV_IN_JOULES;
106    let s = thermal_de_broglie(t) * (-e_ion / (K_BOLTZMANN * t)).exp() / n_e;
107    solve_saha_linear(s)
108}
109
110/// He I → He Saha ionization fraction (24.6 eV).
111///
112/// Returns the fraction of helium that is at least singly ionized (He⁺ or He²⁺).
113/// Statistical weight ratio: g(He⁺)g(e)/g(He) = 2×2/1 = 4.
114/// He⁺ ground state (1s, hydrogen-like), g=2 (electron spin).
115/// Free electron, g=2 (spin).
116/// He ground state (1s², singlet), g=1.
117///
118/// Uses the standard total free-electron Saha form y / (1 − y) = K(T) / n_e.
119/// At z ~ 2000 where He⁺ recombines, H is still fully ionized (Saha X_H ≈ 1
120/// down to z ~ 1500) and He²⁺ has already recombined to He⁺, so
121/// n_e ≈ n_H + y_I·n_He; we approximate y_I = 1 to keep the equation linear,
122/// which introduces ≲4% error in n_e.
123pub fn saha_he_i(z: f64, cosmo: &Cosmology) -> f64 {
124    let t = cosmo.t_cmb * (1.0 + z);
125    let n_he = cosmo.n_he(z);
126    if n_he < 1e-30 {
127        return 1.0;
128    }
129
130    let n_e = cosmo.n_h(z) + n_he;
131
132    let e_ion = E_HE_I_ION_EV * EV_IN_JOULES;
133    let s = 4.0 * thermal_de_broglie(t) * (-e_ion / (K_BOLTZMANN * t)).exp() / n_e;
134    solve_saha_linear(s)
135}
136
137/// Helium electron contribution: free electrons per H atom from He.
138///
139/// x_He = f_He × (y_HeI + 2 × y_HeII)
140/// where y_HeII is the doubly-ionized fraction and y_HeI is the singly-ionized fraction.
141pub fn helium_electron_fraction(z: f64, cosmo: &Cosmology) -> f64 {
142    let f_he = cosmo.y_p / (4.0 * (1.0 - cosmo.y_p));
143    let y_he_ii = saha_he_ii(z, cosmo);
144    let y_he_i = saha_he_i(z, cosmo);
145    // He²⁺ contributes 2 electrons, He⁺ contributes 1.
146    // He⁺-only fraction = y_he_i - y_he_ii, He²⁺ fraction = y_he_ii.
147    // Electrons: (y_he_i - y_he_ii)×1 + y_he_ii×2 = y_he_i + y_he_ii.
148    f_he * (y_he_i + y_he_ii)
149}
150
151// --- Hydrogen recombination (Saha + Peebles TLA) ---
152
153/// Hydrogen Saha ionization fraction.
154///
155/// Solves X_e²N_H / (1−X_e) = (m_e k_B T / 2πℏ²)^{3/2} exp(−E_Rydberg/kT)
156/// for X_e.
157pub fn saha_hydrogen(z: f64, cosmo: &Cosmology) -> f64 {
158    let t = cosmo.t_cmb * (1.0 + z);
159    let n_h = cosmo.n_h(z);
160
161    let s = thermal_de_broglie(t) * (-E_RYDBERG / (K_BOLTZMANN * t)).exp() / n_h;
162    solve_saha_quadratic(s)
163}
164
165/// Case-B recombination coefficient α_B(T) [m³/s].
166///
167/// Péquignot, Petitjean & Boisson (1991) fitting formula with
168/// fudge factor F = 1.125 (Chluba & Thomas 2011):
169///
170///   α_B = F × 10⁻¹⁹ × 4.309 × t^{−0.6166} / (1 + 0.6703 × t^{0.5300})
171///
172/// where t = T / 10⁴ K.
173///
174/// The fudge factor accounts for higher-order corrections to Case-B
175/// recombination (stimulated recombination, two-photon processes).
176/// F = 1.14 was used in the original RECFAST; F = 1.125 is the updated
177/// value from Chluba & Thomas (2011), also used by DarkHistory.
178fn alpha_recomb(t: f64) -> f64 {
179    let tt = t / 1.0e4;
180    let f = 1.125; // Chluba & Thomas (2011)
181    f * 1e-19 * 4.309 * tt.powf(-0.6166) / (1.0 + 0.6703 * tt.powf(0.5300))
182}
183
184/// Photoionization rate from n=2 level [s⁻¹].
185///
186/// From detailed balance with the radiation field at temperature T_CMB:
187///
188///   β_B = α_B(T_rad) × (m_e k_B T_rad / 2πℏ²)^{3/2} × exp(−E_{n=2}/kT_rad)
189///
190/// where E_{n=2} = E_Rydberg/4 = 3.4 eV is the ionization energy from n=2.
191///
192/// **Important**: This uses the radiation temperature T_CMB, NOT the matter
193/// temperature, because the photoionizing radiation field is thermal at T_CMB.
194fn beta_ion(t_rad: f64) -> f64 {
195    let alpha = alpha_recomb(t_rad);
196    // The factor of 1/4 from the n=2 statistical weight is already
197    // built into E_ION_N2 = E_Rydberg/4
198    alpha * thermal_de_broglie(t_rad) * (-E_ION_N2 / (K_BOLTZMANN * t_rad)).exp()
199}
200
201/// Peebles C factor: fraction of excited atoms that reach the ground state.
202///
203/// Decomposition into competing rates:
204///
205/// - `rate_lya_escape`: Lyman-α escape via Sobolev approximation.
206///   Rate = 1/(K_H × n_{1s}) = 8πH / (n_H (1−X_e) λ_Lyα³).
207///   Most Ly-α photons are reabsorbed; only the cosmological redshift
208///   allows escape from the optically thick line.
209///
210/// - `rate_2s1s`: Two-photon decay 2s→1s at rate Λ_{2s} = 8.225 s⁻¹.
211///   Slow but guaranteed (two-photon continuum cannot be reabsorbed).
212///
213/// - `rate_ion`: Photoionization from n=2 at rate β_B.
214///
215/// The C factor is:
216///   C = (rate_escape + Λ_{2s}) / (rate_escape + Λ_{2s} + β_B)
217///
218/// In the standard TLA, 2s and 2p are assumed to be in statistical
219/// equilibrium (fast collisional mixing), so the net de-excitation
220/// rate is the sum of both channels without explicit statistical weights.
221///
222/// When C ≈ 1: de-excitation wins (recombination proceeds).
223/// When C ≈ 0: photoionization wins (recombination is bottlenecked).
224fn peebles_c(z: f64, x_e: f64, cosmo: &Cosmology) -> f64 {
225    let t_rad = cosmo.t_cmb * (1.0 + z);
226    let n_h = cosmo.n_h(z);
227    let h = cosmo.hubble(z);
228
229    // Sobolev optical depth parameter: K_H = λ_Lyα³ / (8π H)
230    let k_h = LAMBDA_LYA.powi(3) / (8.0 * std::f64::consts::PI * h);
231
232    // Number of neutral hydrogen atoms [m⁻³]
233    let n_1s = n_h * (1.0 - x_e).max(0.0);
234
235    // Ly-α escape rate: 1/(K_H × n_{1s})
236    let rate_lya_escape = if n_1s > 1e-30 {
237        1.0 / (k_h * n_1s)
238    } else {
239        1e30
240    };
241
242    // Photoionization rate from n=2
243    let rate_ion = beta_ion(t_rad);
244
245    // C = (escape + two-photon) / (escape + two-photon + photoionization)
246    let rate_down = rate_lya_escape + LAMBDA_2S1S;
247    let denom = rate_down + rate_ion;
248    if denom > 0.0 { rate_down / denom } else { 1.0 }
249}
250
251/// Evaluate the Peebles ODE RHS `dX_h/dz_up = C·α_B·n_H/[H·(1+z)] ·
252/// [X_h² − X_S²·(1−X_h)/(1−X_S)]` at the given (z, X_h).
253///
254/// Here z_up is oriented so that positive `dz_up` corresponds to stepping
255/// _downward_ in z (the physical direction of time). The sign convention
256/// matches `peebles_step`.
257///
258/// NOTE: `alpha_recomb` and `beta_ion` are evaluated at the **radiation
259/// temperature** T_γ = T_cmb · (1+z), not the matter temperature T_m. During
260/// H recombination Compton coupling still enforces T_m ≈ T_γ, so the error is
261/// ≲1%. Full accuracy (≲0.1%, as in RECFAST) would require the coupled
262/// matter-temperature ODE; this solver's purpose is spectral-distortion
263/// templates, where 1–5% disagreement with RECFAST is accepted.
264fn peebles_rhs(z: f64, x_h: f64, cosmo: &Cosmology) -> f64 {
265    let t = cosmo.t_cmb * (1.0 + z);
266    let n_h = cosmo.n_h(z);
267    let h = cosmo.hubble(z);
268
269    let c_r = peebles_c(z, x_h.min(1.0), cosmo);
270    let alpha = alpha_recomb(t);
271
272    let x_saha = saha_hydrogen(z, cosmo).min(1.0);
273    let one_minus_xs = (1.0 - x_saha).max(1e-30);
274
275    let rhs_factor = c_r * alpha * n_h / (h * (1.0 + z));
276    let saha_term = x_saha * x_saha * (1.0 - x_h).max(0.0) / one_minus_xs;
277    rhs_factor * (x_h * x_h - saha_term)
278}
279
280/// Single trapezoidal (Heun's method) step of the Peebles ODE.
281///
282/// Steps x_h from z_prev = z_new + dz down to z_new:
283///
284/// ```text
285///   k1 = f(z_prev, x_h)
286///   k2 = f(z_new,  x_h − dz · k1)
287///   x_new = x_h − dz · (k1 + k2) / 2
288/// ```
289///
290/// This is second-order accurate in dz (O(dz²) local truncation error),
291/// upgraded from forward Euler (audit M1 / recomb). The final clamp to
292/// `[1e-5, 1.0]` is a safety net; removing it would let step overshoot
293/// produce negative X_h at large dz — if it fires it signals that the
294/// outer step size is too coarse.
295fn peebles_step(z_new: f64, x_h: f64, dz: f64, cosmo: &Cosmology) -> f64 {
296    let z_prev = z_new + dz;
297    let k1 = peebles_rhs(z_prev, x_h, cosmo);
298    // Evaluate k2 at the predictor, clamped to avoid feeding unphysical
299    // values into saha_term / peebles_c.
300    let x_pred = (x_h - dz * k1).clamp(1e-5, 1.0);
301    let k2 = peebles_rhs(z_new, x_pred, cosmo);
302    (x_h - 0.5 * dz * (k1 + k2)).clamp(1e-5, 1.0)
303}
304
305/// Find the redshift where the Saha hydrogen X_e first drops below 0.99.
306///
307/// This is where the Peebles correction becomes significant and we
308/// switch from the Saha equation to the TLA ODE.
309fn find_saha_switch(cosmo: &Cosmology) -> f64 {
310    let mut z = 1800.0;
311    while z > 1000.0 {
312        if saha_hydrogen(z, cosmo) < 0.99 {
313            return z + 1.0;
314        }
315        z -= 1.0;
316    }
317    1500.0
318}
319
320/// Ionization fraction X_e(z) with Peebles TLA correction.
321///
322/// Returns the total free electron fraction (hydrogen + helium) per
323/// hydrogen atom.
324///
325/// ## Regimes
326///
327/// - z > 8000: Fully ionized H; He from Saha equations.
328/// - z_switch < z ≤ 8000: Saha equilibrium for H + He.
329/// - z ≤ z_switch: Peebles three-level atom ODE for H, plus Saha He.
330///
331/// ## Saha-subtracted ODE
332///
333/// The raw Peebles ODE has catastrophic cancellation: α_B n_H X_e²
334/// and β_B (1−X_e) are both ~10² s⁻¹ but their difference is ~10⁻⁴.
335/// Using the Saha relation β_B = α_B X_S² n_H / (1−X_S), we rewrite:
336///
337///   dX_e/dz = C × α_B × n_H / (H(1+z)) × [X_e² − X_S² (1−X_e)/(1−X_S)]
338///
339/// This is O(X_e − X_S) near equilibrium, eliminating the cancellation.
340///
341/// References:
342/// - Peebles (1968) — Three-level atom
343/// - Seager, Sasselov & Scott (1999) — RECFAST
344/// - Liu et al. (2020) — DarkHistory implementation
345pub fn ionization_fraction(z: f64, cosmo: &Cosmology) -> f64 {
346    if z > 8000.0 {
347        return 1.0 + helium_electron_fraction(z, cosmo);
348    }
349
350    let z_switch = find_saha_switch(cosmo);
351
352    if z > z_switch {
353        let x_h = saha_hydrogen(z, cosmo).min(1.0);
354        return x_h + helium_electron_fraction(z, cosmo);
355    }
356
357    // Peebles TLA ODE from z_switch down to z
358    let z_end = z.max(1.0);
359    let x_h_start = saha_hydrogen(z_switch, cosmo).min(1.0);
360
361    let dz_step = 0.5_f64;
362    let n_steps = ((z_switch - z_end) / dz_step).ceil() as usize;
363    let n_steps = n_steps.max(1);
364    let dz_actual = (z_switch - z_end) / n_steps as f64;
365
366    let mut x_e = x_h_start;
367
368    for i in 0..n_steps {
369        let z_new = z_switch - (i + 1) as f64 * dz_actual;
370        x_e = peebles_step(z_new, x_e, dz_actual, cosmo);
371    }
372
373    x_e + helium_electron_fraction(z_end, cosmo)
374}
375
376// --- Cached recombination history ---
377
378/// Precomputed recombination history for fast X_e(z) lookups.
379///
380/// Integrates the Peebles ODE once on construction and stores a table
381/// of (z, X_e) pairs. Subsequent lookups use binary search + linear
382/// interpolation, making each call O(log N) instead of O(N_ode).
383///
384/// For z above the Peebles regime (z > z_switch ~ 1575), the cheap
385/// Saha formula is used directly (no table needed).
386pub struct RecombinationHistory {
387    /// Redshifts in descending order (z_switch, z_switch − dz, ..., 1.0)
388    z_table: Vec<f64>,
389    /// Total X_e (hydrogen + helium) at each redshift
390    x_e_table: Vec<f64>,
391    /// Redshift where Saha → Peebles switch occurs
392    z_switch: f64,
393    /// Uniform spacing of z_table (descending): z_table[i] = z_switch − i·dz_table
394    dz_table: f64,
395    /// Reference cosmology (needed for Saha evaluations above z_switch)
396    cosmo: Cosmology,
397}
398
399impl RecombinationHistory {
400    /// Build the recombination history table for a given cosmology.
401    ///
402    /// Integrates the Peebles ODE from z_switch down to z=1 with dz=0.5,
403    /// storing total X_e (H + He) at each step.
404    pub fn new(cosmo: &Cosmology) -> Self {
405        let z_switch = find_saha_switch(cosmo);
406        let z_end = 1.0_f64;
407        let dz_step = 0.5_f64;
408        let n_steps = ((z_switch - z_end) / dz_step).ceil() as usize;
409        let n_steps = n_steps.max(1);
410        let dz_actual = (z_switch - z_end) / n_steps as f64;
411
412        let mut z_table = Vec::with_capacity(n_steps + 1);
413        let mut x_e_table = Vec::with_capacity(n_steps + 1);
414
415        let x_h_start = saha_hydrogen(z_switch, cosmo).min(1.0);
416        let x_e_start = x_h_start + helium_electron_fraction(z_switch, cosmo);
417        z_table.push(z_switch);
418        x_e_table.push(x_e_start);
419
420        let mut x_h = x_h_start;
421
422        for i in 0..n_steps {
423            let z_new = z_switch - (i + 1) as f64 * dz_actual;
424            x_h = peebles_step(z_new, x_h, dz_actual, cosmo);
425
426            let x_e_total = x_h + helium_electron_fraction(z_new.max(1.0), cosmo);
427            z_table.push(z_new);
428            x_e_table.push(x_e_total);
429        }
430
431        RecombinationHistory {
432            z_table,
433            x_e_table,
434            z_switch,
435            dz_table: dz_actual,
436            cosmo: cosmo.clone(),
437        }
438    }
439
440    /// Look up X_e(z) using the cached table.
441    ///
442    /// - z > 8000: fully ionized (Saha for He)
443    /// - z_switch < z ≤ 8000: Saha for H + He (cheap, no table)
444    /// - z ≤ z_switch: interpolate from precomputed table
445    pub fn x_e(&self, z: f64) -> f64 {
446        if z > 8000.0 {
447            1.0 + helium_electron_fraction(z, &self.cosmo)
448        } else if z > self.z_switch {
449            let x_h = saha_hydrogen(z, &self.cosmo).min(1.0);
450            x_h + helium_electron_fraction(z, &self.cosmo)
451        } else if z <= self.z_table[self.z_table.len() - 1] {
452            // Below the table: return the last value (freeze-out)
453            self.x_e_table[self.x_e_table.len() - 1]
454        } else {
455            // Direct indexing: z_table is uniform in z (descending), so the
456            // bracketing index is idx = floor((z_switch − z)/dz_table). Clamp
457            // to the last interior cell so idx+1 is always a valid node.
458            let raw = (self.z_switch - z) / self.dz_table;
459            let n = self.z_table.len();
460            let idx = (raw as usize).min(n - 2);
461
462            let z_hi = self.z_table[idx];
463            let z_lo = self.z_table[idx + 1];
464            let x_hi = self.x_e_table[idx];
465            let x_lo = self.x_e_table[idx + 1];
466            let t = (z_hi - z) / (z_hi - z_lo);
467            x_hi + t * (x_lo - x_hi)
468        }
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn test_fully_ionized_high_z() {
478        let cosmo = Cosmology::default();
479        // At z=10^6, He is doubly ionized: X_e = 1 + 2*f_He
480        let x_e = ionization_fraction(1e6, &cosmo);
481        assert!(
482            x_e > 1.0,
483            "Should be fully ionized with He at z=10^6: X_e = {x_e}"
484        );
485        assert!(
486            (x_e - (1.0 + 2.0 * F_HE)).abs() < 0.01,
487            "X_e = {x_e}, expected {}",
488            1.0 + 2.0 * F_HE
489        );
490    }
491
492    #[test]
493    fn test_freeze_out() {
494        let cosmo = Cosmology::default();
495        let x_e = ionization_fraction(100.0, &cosmo);
496        // RECFAST: X_e(100) ~ 2-4e-4 for this cosmology
497        assert!(
498            x_e > 1e-4 && x_e < 5e-3,
499            "Freeze-out X_e should be ~2e-4: got {x_e}"
500        );
501    }
502
503    #[test]
504    fn test_recombination_physical_values() {
505        let cosmo = Cosmology::default();
506
507        // z=1400: not yet deeply recombined
508        let x_1400 = ionization_fraction(1400.0, &cosmo);
509        assert!(
510            x_1400 > 0.5,
511            "X_e(1400) should be > 0.5 (early recombination): got {x_1400}"
512        );
513
514        // z=1100: mid-recombination, RECFAST gives ~0.14 for this cosmology
515        let x_1100 = ionization_fraction(1100.0, &cosmo);
516        assert!(
517            x_1100 > 0.10 && x_1100 < 0.20,
518            "X_e(1100) should be ~0.14 (RECFAST): got {x_1100}"
519        );
520
521        // z=800: mostly recombined
522        let x_800 = ionization_fraction(800.0, &cosmo);
523        assert!(
524            x_800 < 0.01,
525            "X_e(800) should be < 0.01 (mostly recombined): got {x_800}"
526        );
527
528        // z=200: freeze-out regime
529        let x_200 = ionization_fraction(200.0, &cosmo);
530        assert!(
531            x_200 > 1e-5 && x_200 < 0.01,
532            "X_e(200) should be in [1e-5, 0.01]: got {x_200}"
533        );
534
535        // Monotonic decrease from z=1500 to z=200
536        let zs = [
537            1500.0, 1400.0, 1300.0, 1200.0, 1100.0, 1000.0, 800.0, 600.0, 400.0, 200.0,
538        ];
539        let xs: Vec<f64> = zs.iter().map(|&z| ionization_fraction(z, &cosmo)).collect();
540        for i in 1..xs.len() {
541            assert!(
542                xs[i] <= xs[i - 1] + 1e-10,
543                "X_e should decrease monotonically: X_e({})={:.4e} > X_e({})={:.4e}",
544                zs[i],
545                xs[i],
546                zs[i - 1],
547                xs[i - 1]
548            );
549        }
550
551        // No hard jump around z=200 (smooth transition)
552        let x_201 = ionization_fraction(201.0, &cosmo);
553        let x_199 = ionization_fraction(199.0, &cosmo);
554        let ratio = if x_199 > 1e-30 { x_201 / x_199 } else { 1.0 };
555        assert!(
556            ratio > 0.5 && ratio < 2.0,
557            "No hard jump at z=200: X_e(201)={x_201:.4e}, X_e(199)={x_199:.4e}, ratio={ratio:.2}"
558        );
559    }
560
561    #[test]
562    fn test_helium_saha_transitions() {
563        let cosmo = Cosmology::default();
564
565        // At z=50000 (T ~ 136,000 K): He should be fully doubly ionized
566        let y_ii = saha_he_ii(50000.0, &cosmo);
567        assert!(y_ii > 0.99, "He²⁺ fraction at z=50000 should be ~1: {y_ii}");
568
569        // At z=10000 (T ~ 27,000 K): He II recombining
570        let y_ii_mid = saha_he_ii(10000.0, &cosmo);
571        eprintln!("He²⁺ at z=10000: {y_ii_mid:.3}");
572
573        // At z=3000 (T ~ 8,200 K): He I should be mostly ionized
574        let y_i = saha_he_i(3000.0, &cosmo);
575        assert!(y_i > 0.5, "He⁺ fraction at z=3000 should be >0.5: {y_i}");
576
577        // (High-z X_e = 1 + 2f_He tested in test_fully_ionized_high_z)
578
579        // X_e should decrease smoothly through He recombination
580        let x_e_8k = ionization_fraction(8000.0, &cosmo);
581        let x_e_5k = ionization_fraction(5000.0, &cosmo);
582        eprintln!("X_e(z=8000)={x_e_8k:.4}, X_e(z=5000)={x_e_5k:.4}");
583        assert!(
584            x_e_8k >= x_e_5k,
585            "X_e should decrease from z=8000 to z=5000"
586        );
587    }
588
589    #[test]
590    fn test_recombination_history_matches_uncached() {
591        let cosmo = Cosmology::default();
592        let history = RecombinationHistory::new(&cosmo);
593
594        let test_zs = [
595            1e6, 5e4, 8000.0, 5000.0, 1500.0, 1400.0, 1200.0, 1100.0, 1000.0, 800.0, 500.0, 200.0,
596            100.0, 10.0,
597        ];
598        for &z in &test_zs {
599            let cached = history.x_e(z);
600            let uncached = ionization_fraction(z, &cosmo);
601            let rel_err = if uncached.abs() > 1e-10 {
602                (cached - uncached).abs() / uncached.abs()
603            } else {
604                (cached - uncached).abs()
605            };
606            assert!(
607                rel_err < 0.01,
608                "Cached vs uncached mismatch at z={z}: cached={cached:.6e}, \
609                 uncached={uncached:.6e}, rel_err={rel_err:.3e}"
610            );
611        }
612    }
613
614    #[test]
615    fn test_recombination_history_monotonic() {
616        let cosmo = Cosmology::default();
617        let history = RecombinationHistory::new(&cosmo);
618
619        let zs: Vec<f64> = (100..=2000).rev().step_by(10).map(|z| z as f64).collect();
620        let xs: Vec<f64> = zs.iter().map(|&z| history.x_e(z)).collect();
621        for i in 1..xs.len() {
622            assert!(
623                xs[i] <= xs[i - 1] + 1e-10,
624                "Cached X_e not monotonic: X_e({})={:.4e} > X_e({})={:.4e}",
625                zs[i],
626                xs[i],
627                zs[i - 1],
628                xs[i - 1]
629            );
630        }
631    }
632
633    #[test]
634    fn test_recombination_history_interpolation_smooth() {
635        let cosmo = Cosmology::default();
636        let history = RecombinationHistory::new(&cosmo);
637
638        let z_a = 1200.0;
639        let z_b = 1200.25;
640        let z_c = 1200.5;
641        let x_a = history.x_e(z_a);
642        let x_b = history.x_e(z_b);
643        let x_c = history.x_e(z_c);
644
645        assert!(
646            (x_a >= x_b && x_b >= x_c) || (x_a <= x_b && x_b <= x_c),
647            "Interpolation not monotonic: X_e({z_a})={x_a:.6e}, \
648             X_e({z_b})={x_b:.6e}, X_e({z_c})={x_c:.6e}"
649        );
650    }
651
652    /// Compare X_e at key redshifts against RECFAST literature values.
653    ///
654    /// Peebles 3-level atom with fudge factor F=1.125 (Chluba & Thomas 2011)
655    /// agrees with RECFAST (Seager, Sasselov & Scott 1999) to ~1-5%.
656    ///
657    /// For the default cosmology (T_CMB=2.726, Ω_b=0.044, h=0.71, Y_p=0.24):
658    ///   X_e(1100) ≈ 0.14 (RECFAST: 0.142 for similar params)
659    ///   X_e(800)  ≈ 3e-3 (RECFAST: 0.0034)
660    ///   X_e(200)  ≈ 3e-4 (freeze-out)
661    #[test]
662    fn test_xe_vs_recfast_milestones() {
663        let cosmo = Cosmology::default();
664
665        let xe_1100 = ionization_fraction(1100.0, &cosmo);
666        let xe_800 = ionization_fraction(800.0, &cosmo);
667        let xe_200 = ionization_fraction(200.0, &cosmo);
668        eprintln!("X_e(1100) = {xe_1100:.4}  [RECFAST: ~0.14]");
669        eprintln!("X_e(800)  = {xe_800:.4e}  [RECFAST: ~3e-3]");
670        eprintln!("X_e(200)  = {xe_200:.4e}  [freeze-out: ~3e-4]");
671
672        // z=1100: mid-recombination, RECFAST gives ~0.14
673        assert!(
674            xe_1100 > 0.10 && xe_1100 < 0.20,
675            "X_e(1100) = {xe_1100:.4}, RECFAST expects ~0.14 ± 0.03"
676        );
677
678        // z=800: mostly recombined, RECFAST gives ~3e-3
679        assert!(
680            xe_800 > 5e-4 && xe_800 < 0.01,
681            "X_e(800) = {xe_800:.4e}, RECFAST expects ~3e-3"
682        );
683
684        // z=200: freeze-out, should be ~2-4 × 10^-4
685        assert!(
686            xe_200 > 1e-4 && xe_200 < 2e-3,
687            "X_e(200) = {xe_200:.4e}, expected freeze-out ~3e-4"
688        );
689    }
690}