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}