spectroxide/
cosmology.rs

1//! Background cosmology: Hubble rate, cosmic time, number densities.
2//!
3//! Implements a flat ΛCDM background with radiation (photons + neutrinos).
4
5use crate::constants::*;
6
7/// Cosmological parameters with cached derived quantities.
8#[derive(Debug, Clone)]
9pub struct Cosmology {
10    /// CMB temperature today, in K.
11    pub t_cmb: f64,
12    /// Physical baryon density ω_b = Ω_b h²
13    pub omega_b: f64,
14    /// Physical CDM density ω_cdm = Ω_cdm h²
15    pub omega_cdm: f64,
16    /// Dimensionless Hubble parameter h = H₀/(100 km/s/Mpc)
17    pub h: f64,
18    /// Effective number of neutrino species
19    pub n_eff: f64,
20    /// Primordial helium mass fraction Y_p
21    pub y_p: f64,
22
23    // --- Cached derived quantities (computed once at construction) ---
24    cached_h0: f64,
25    cached_omega_b_frac: f64,
26    cached_omega_cdm_frac: f64,
27    cached_omega_m: f64,
28    cached_omega_gamma: f64,
29    cached_omega_rel: f64,
30    cached_omega_lambda: f64,
31    /// (1-Y_p) * rho_b0 / M_PROTON — multiply by (1+z)³ to get n_H(z)
32    cached_n_h_prefactor: f64,
33    /// Y_p / (4(1-Y_p))
34    cached_f_he: f64,
35    /// 3 Ω_b / (4 Ω_γ) — multiply by 1/(1+z) to get R(z)
36    cached_baryon_photon_prefactor: f64,
37    /// ρ_γ(z=0) = (π²/15)(kT₀)⁴/(ℏc)³ — multiply by (1+z)⁴ to get ρ_γ(z)
38    cached_rho_gamma_0: f64,
39}
40
41impl Cosmology {
42    /// Construct a Cosmology with all cached derived quantities.
43    /// Validate cosmological parameters.
44    ///
45    /// Returns `Err` with a descriptive message if any parameter is
46    /// non-physical or would cause numerical failure.
47    pub fn validate(&self) -> Result<(), String> {
48        if !self.t_cmb.is_finite() || self.t_cmb <= 0.0 {
49            return Err(format!(
50                "t_cmb must be positive and finite, got {}",
51                self.t_cmb
52            ));
53        }
54        if !self.omega_b.is_finite() || self.omega_b <= 0.0 {
55            return Err(format!(
56                "omega_b must be positive and finite, got {}",
57                self.omega_b
58            ));
59        }
60        if !self.omega_cdm.is_finite() || self.omega_cdm < 0.0 {
61            return Err(format!(
62                "omega_cdm must be non-negative and finite, got {}",
63                self.omega_cdm
64            ));
65        }
66        if !self.h.is_finite() || self.h <= 0.0 {
67            return Err(format!("h must be positive and finite, got {}", self.h));
68        }
69        if self.h > 10.0 {
70            return Err(format!(
71                "h={} implies H0={} km/s/Mpc, which is nonsensical",
72                self.h,
73                self.h * 100.0
74            ));
75        }
76        if !self.n_eff.is_finite() || self.n_eff < 0.0 || self.n_eff > 20.0 {
77            return Err(format!("n_eff must be in [0, 20], got {}", self.n_eff));
78        }
79        if !self.y_p.is_finite() || self.y_p < 0.0 || self.y_p >= 1.0 {
80            return Err(format!("y_p must be in [0, 1), got {}", self.y_p));
81        }
82        Ok(())
83    }
84
85    /// Construct a Cosmology from dimensionless parameters, validating the inputs.
86    ///
87    /// Returns `Err` if any parameter is non-finite, negative where physical
88    /// positivity is required, or outside the supported range.
89    pub fn new(
90        t_cmb: f64,
91        omega_b: f64,
92        omega_cdm: f64,
93        h: f64,
94        n_eff: f64,
95        y_p: f64,
96    ) -> Result<Self, String> {
97        // Validate before computing derived quantities to avoid div-by-zero / Inf
98        let tmp = Cosmology {
99            t_cmb,
100            omega_b,
101            omega_cdm,
102            h,
103            n_eff,
104            y_p,
105            cached_h0: 0.0,
106            cached_omega_b_frac: 0.0,
107            cached_omega_cdm_frac: 0.0,
108            cached_omega_m: 0.0,
109            cached_omega_gamma: 0.0,
110            cached_omega_rel: 0.0,
111            cached_omega_lambda: 0.0,
112            cached_n_h_prefactor: 0.0,
113            cached_f_he: 0.0,
114            cached_baryon_photon_prefactor: 0.0,
115            cached_rho_gamma_0: 0.0,
116        };
117        tmp.validate()?;
118        Ok(Self::new_unchecked(
119            t_cmb, omega_b, omega_cdm, h, n_eff, y_p,
120        ))
121    }
122
123    /// Construct a Cosmology without validation.
124    ///
125    /// Escape hatch for hardcoded presets and test fixtures where the inputs
126    /// are known a priori to be valid. Using non-finite or zero `h` / `y_p`
127    /// here will produce `NaN`/`Inf` in derived quantities — prefer [`Self::new`]
128    /// for any input the caller does not fully control.
129    pub fn new_unchecked(
130        t_cmb: f64,
131        omega_b: f64,
132        omega_cdm: f64,
133        h: f64,
134        n_eff: f64,
135        y_p: f64,
136    ) -> Self {
137        let h0 = 100.0 * h * KM_PER_MPC;
138        let h2 = h * h;
139        let omega_b_frac = omega_b / h2;
140        let omega_cdm_frac = omega_cdm / h2;
141        let omega_m = omega_b_frac + omega_cdm_frac;
142
143        let rho_gamma = std::f64::consts::PI.powi(2) / 15.0 * K_BOLTZMANN.powi(4) * t_cmb.powi(4)
144            / (HBAR.powi(3) * C_LIGHT.powi(3));
145        let rho_crit = 3.0 * h0 * h0 * C_LIGHT * C_LIGHT / (8.0 * std::f64::consts::PI * G_NEWTON);
146        let omega_gamma = rho_gamma / rho_crit;
147        let omega_rel =
148            omega_gamma * (1.0 + n_eff * (7.0 / 8.0) * (4.0_f64 / 11.0).powf(4.0 / 3.0));
149        let omega_lambda = 1.0 - omega_m - omega_rel;
150
151        // n_H prefactor: (1-Y_p) * rho_b0 / M_PROTON
152        // rho_b0 = Omega_b * rho_crit (rho_crit without c² since we use SI densities)
153        let rho_crit_mass = 3.0 * h0 * h0 / (8.0 * std::f64::consts::PI * G_NEWTON);
154        let rho_b0 = omega_b_frac * rho_crit_mass;
155        let n_h_prefactor = (1.0 - y_p) * rho_b0 / M_PROTON;
156
157        let f_he = y_p / (4.0 * (1.0 - y_p));
158        let baryon_photon_prefactor = 3.0 * omega_b_frac / (4.0 * omega_gamma);
159
160        Cosmology {
161            t_cmb,
162            omega_b,
163            omega_cdm,
164            h,
165            n_eff,
166            y_p,
167            cached_h0: h0,
168            cached_omega_b_frac: omega_b_frac,
169            cached_omega_cdm_frac: omega_cdm_frac,
170            cached_omega_m: omega_m,
171            cached_omega_gamma: omega_gamma,
172            cached_omega_rel: omega_rel,
173            cached_omega_lambda: omega_lambda,
174            cached_n_h_prefactor: n_h_prefactor,
175            cached_f_he: f_he,
176            cached_baryon_photon_prefactor: baryon_photon_prefactor,
177            cached_rho_gamma_0: rho_gamma,
178        }
179    }
180
181    /// Planck 2015 cosmological parameters (matching CosmoTherm DI files).
182    pub fn planck2015() -> Self {
183        Self::new_unchecked(2.726, 0.02225, 0.1198, 0.6727, 3.046, 0.2467)
184    }
185
186    /// Planck 2018 cosmological parameters (Planck Collaboration VI, 2020).
187    pub fn planck2018() -> Self {
188        // omega_b = Omega_b * h^2 = 0.04930 * 0.6736^2 = 0.02237
189        // omega_cdm = (Omega_m - Omega_b) * h^2 = (0.3153 - 0.04930) * 0.6736^2 = 0.1200
190        Self::new_unchecked(2.7255, 0.02237, 0.1200, 0.6736, 3.044, 0.2454)
191    }
192
193    /// H₀ in 1/s
194    #[inline]
195    pub fn h0(&self) -> f64 {
196        self.cached_h0
197    }
198
199    /// Ω_b = ω_b / h²
200    #[inline]
201    pub fn omega_b_frac(&self) -> f64 {
202        self.cached_omega_b_frac
203    }
204
205    /// Ω_cdm = ω_cdm / h²
206    #[inline]
207    pub fn omega_cdm_frac(&self) -> f64 {
208        self.cached_omega_cdm_frac
209    }
210
211    /// Ω_m = Ω_b + Ω_cdm
212    #[inline]
213    pub fn omega_m(&self) -> f64 {
214        self.cached_omega_m
215    }
216
217    /// Ω_γ (photon density parameter)
218    #[inline]
219    pub fn omega_gamma(&self) -> f64 {
220        self.cached_omega_gamma
221    }
222
223    /// Ω_rel (all relativistic species: photons + neutrinos)
224    #[inline]
225    pub fn omega_rel(&self) -> f64 {
226        self.cached_omega_rel
227    }
228
229    /// Ω_Λ = 1 - Ω_m - Ω_rel (flat universe)
230    #[inline]
231    pub fn omega_lambda(&self) -> f64 {
232        self.cached_omega_lambda
233    }
234
235    /// E(z) = H(z)/H₀ = sqrt(Ω_m(1+z)³ + Ω_rel(1+z)⁴ + Ω_Λ)
236    #[inline]
237    pub fn e_of_z(&self, z: f64) -> f64 {
238        let opz = 1.0 + z;
239        (self.cached_omega_m * opz.powi(3)
240            + self.cached_omega_rel * opz.powi(4)
241            + self.cached_omega_lambda)
242            .sqrt()
243    }
244
245    /// Dimensionless photon temperature: θ_z(z) = k_B T_z / (m_e c²)
246    /// Uses this cosmology's T_CMB rather than the hardcoded default.
247    #[inline]
248    pub fn theta_z(&self, z: f64) -> f64 {
249        crate::constants::K_BOLTZMANN * self.t_cmb * (1.0 + z) / crate::constants::M_E_C2
250    }
251
252    /// Hubble rate H(z) in 1/s
253    #[inline]
254    pub fn hubble(&self, z: f64) -> f64 {
255        self.cached_h0 * self.e_of_z(z)
256    }
257
258    /// dt/dz in seconds
259    pub fn dt_dz(&self, z: f64) -> f64 {
260        -1.0 / (self.hubble(z) * (1.0 + z))
261    }
262
263    /// Matter-radiation equality redshift
264    pub fn z_eq(&self) -> f64 {
265        self.cached_omega_m / self.cached_omega_rel - 1.0
266    }
267
268    /// Hydrogen number density at z [1/m³]
269    /// N_H = (1 - Y_p) ρ_b / m_p
270    #[inline]
271    pub fn n_h(&self, z: f64) -> f64 {
272        self.cached_n_h_prefactor * (1.0 + z).powi(3)
273    }
274
275    /// Helium number density at z [1/m³]
276    /// N_He = Y_p / (4(1-Y_p)) × N_H
277    #[inline]
278    pub fn n_he(&self, z: f64) -> f64 {
279        self.cached_f_he * self.n_h(z)
280    }
281
282    /// Helium-to-hydrogen ratio f_He = Y_p / (4(1-Y_p))
283    #[inline]
284    pub fn f_he(&self) -> f64 {
285        self.cached_f_he
286    }
287
288    /// Free electron density at z (1/m³), given ionization fraction X_e.
289    #[inline]
290    pub fn n_e(&self, z: f64, x_e: f64) -> f64 {
291        x_e * self.n_h(z)
292    }
293
294    /// Thomson scattering time t_C = 1/(σ_T N_e c), in s.
295    #[inline]
296    pub fn t_compton(&self, z: f64, x_e: f64) -> f64 {
297        1.0 / (SIGMA_THOMSON * self.n_e(z, x_e) * C_LIGHT)
298    }
299
300    /// Photon energy density ρ_γ at z [J/m³]
301    #[inline]
302    pub fn rho_gamma(&self, z: f64) -> f64 {
303        self.cached_rho_gamma_0 * (1.0 + z).powi(4)
304    }
305
306    /// Photon number density n_γ at z [1/m³]
307    pub fn n_gamma(&self, z: f64) -> f64 {
308        // n_γ = (2ζ(3)/π²) (kT/ℏc)³
309        let kt_over_hbar_c = K_BOLTZMANN * self.t_cmb * (1.0 + z) / (HBAR * C_LIGHT);
310        2.0 * ZETA_3 / std::f64::consts::PI.powi(2) * kt_over_hbar_c.powi(3)
311    }
312
313    /// Baryon-to-photon energy density ratio R(z) = 3ρ_b/(4ρ_γ).
314    ///
315    /// In the tight-coupling limit this sets the sound speed:
316    ///   c_s² = 1 / (3(1+R)).
317    #[inline]
318    pub fn baryon_photon_ratio(&self, z: f64) -> f64 {
319        self.cached_baryon_photon_prefactor / (1.0 + z)
320    }
321
322    /// Compton y-parameter integrated from z' = 0 to z.
323    ///
324    /// y_C(z) = ∫₀ᶻ (kT_e / m_e c²) × σ_T n_e c / ((1+z') H(z')) dz'
325    ///
326    /// Quantifies the total Compton optical depth for energy exchange.
327    /// At z >> 1100: y_C >> 1 (efficient Comptonization).
328    /// At z << 1100: y_C << 1 (Compton scattering inefficient).
329    ///
330    /// Uses 128-point midpoint quadrature in ln(1+z).
331    pub fn compton_y_parameter(&self, z: f64) -> f64 {
332        if z < 1e-6 {
333            return 0.0;
334        }
335        let recomb = crate::recombination::RecombinationHistory::new(self);
336        self.compton_y_parameter_with_recomb(z, &recomb)
337    }
338
339    /// Compton y-parameter with a pre-built RecombinationHistory.
340    ///
341    /// Use this variant in loops to avoid rebuilding the recombination
342    /// table (~3000 ODE steps) on every call.
343    pub fn compton_y_parameter_with_recomb(
344        &self,
345        z: f64,
346        recomb: &crate::recombination::RecombinationHistory,
347    ) -> f64 {
348        if z < 1e-6 {
349            return 0.0;
350        }
351
352        let ln_min = 0.0_f64; // ln(1+0)
353        let ln_max = (1.0 + z).ln();
354
355        let n = 128;
356        let h = (ln_max - ln_min) / (n as f64);
357        let mut result = 0.0;
358
359        for i in 0..n {
360            let u = ln_min + (i as f64 + 0.5) * h;
361            let zp = u.exp() - 1.0;
362            let opz = 1.0 + zp;
363
364            let x_e = recomb.x_e(zp);
365            let n_e = self.n_e(zp, x_e);
366            // Matter temperature tracks T_γ while Compton coupling is strong
367            // (z ≳ z_dec ≈ 200) and drops as (1+z)² after decoupling. Using
368            // T_γ below z_dec overestimates the integrand and, multiplied by
369            // the already-tiny post-recombination n_e, produces a spurious
370            // contribution to J_Compton (audit M1 / infra). The n_e factor
371            // makes this a few-% effect at worst, but the bias is avoidable.
372            const Z_DEC: f64 = 200.0;
373            let theta_e = if zp > Z_DEC {
374                K_BOLTZMANN * self.t_cmb * opz / M_E_C2
375            } else {
376                // T_m = T_γ(z_dec) · ((1+z)/(1+z_dec))² = T_cmb · (1+z)² / (1+z_dec)
377                let ratio = opz / (1.0 + Z_DEC);
378                K_BOLTZMANN * self.t_cmb * (1.0 + Z_DEC) * ratio * ratio / M_E_C2
379            };
380
381            // Integrand in ln(1+z): θ_e × σ_T × c × n_e / H(z')
382            // The (1+z) from dz = (1+z) d(ln(1+z)) cancels with the 1/(1+z) in the formula
383            result += theta_e * SIGMA_THOMSON * C_LIGHT * n_e / self.hubble(zp) * h;
384        }
385
386        result
387    }
388
389    /// Cosmic time t(z) by numerical integration, in s.
390    ///
391    /// Integrates from z_upper (≈ 10^9) down to z.
392    pub fn cosmic_time(&self, z: f64) -> f64 {
393        let u_low = (1.0 + z).ln();
394        let u_high = (1.0 + 1.0e9_f64).ln();
395
396        let n = 64;
397        let h = (u_high - u_low) / (n as f64);
398        let mut result = 0.0;
399        for i in 0..n {
400            let u = u_low + (i as f64 + 0.5) * h;
401            let zp = u.exp() - 1.0;
402            let dzp_du = 1.0 + zp;
403            result += (1.0 / (self.hubble(zp) * (1.0 + zp))) * dzp_du * h;
404        }
405        result
406    }
407}
408
409impl Default for Cosmology {
410    /// Default parameters matching Chluba (2013) Green's function paper.
411    ///
412    /// These are intentionally **not** the latest Planck values. The defaults
413    /// match CosmoTherm v1.0.3 (Y_p=0.24, T₀=2.726 K, Ω_m=0.26, Ω_b=0.044,
414    /// h=0.71, N_eff=3.046) so that PDE output can be validated against
415    /// published CosmoTherm results without cosmology mismatch.
416    ///
417    /// For Planck-era parameters use [`Cosmology::planck2015`] or
418    /// [`Cosmology::planck2018`].
419    fn default() -> Self {
420        Self::new_unchecked(
421            T_CMB_0,
422            0.044 * 0.71 * 0.71, // Ω_b=0.044, h=0.71
423            (0.26 - 0.044) * 0.71 * 0.71,
424            0.71,
425            N_EFF,
426            Y_P,
427        )
428    }
429}
430
431/// Midpoint quadrature nodes and weights on [a, b].
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    #[test]
437    fn test_default_params() {
438        let cosmo = Cosmology::default();
439        assert!(
440            (cosmo.omega_m() - 0.26).abs() < 0.001,
441            "Omega_m = {}, expected ~0.26",
442            cosmo.omega_m()
443        );
444        assert!(
445            (cosmo.omega_b_frac() - 0.044).abs() < 0.001,
446            "Omega_b = {}, expected ~0.044",
447            cosmo.omega_b_frac()
448        );
449    }
450
451    #[test]
452    fn test_hubble_today() {
453        let cosmo = Cosmology::default();
454        let h0 = cosmo.hubble(0.0);
455        let h0_expected = 100.0 * 0.71 * KM_PER_MPC;
456        assert!(
457            (h0 - h0_expected).abs() / h0_expected < 1e-10,
458            "H(0) = {h0:.3e}, expected {h0_expected:.3e}"
459        );
460    }
461
462    #[test]
463    fn test_z_eq() {
464        let cosmo = Cosmology::default();
465        let z_eq = cosmo.z_eq();
466        // For default params (Ω_m=0.26, h=0.71, T_cmb=2.726), z_eq ≈ 3300
467        assert!(
468            z_eq > 3000.0 && z_eq < 3600.0,
469            "z_eq = {z_eq}, expected ~3300 for default cosmology"
470        );
471    }
472
473    #[test]
474    fn test_e_of_z_today() {
475        let cosmo = Cosmology::default();
476        assert!((cosmo.e_of_z(0.0) - 1.0).abs() < 1e-10);
477    }
478
479    #[test]
480    fn test_radiation_dominated() {
481        let cosmo = Cosmology::default();
482        let z = 1.0e6;
483        let e = cosmo.e_of_z(z);
484        let e_rad = cosmo.omega_rel().sqrt() * (1.0 + z).powi(2);
485        assert!(
486            (e - e_rad).abs() / e_rad < 0.01,
487            "E({z}) = {e:.3e}, radiation approx = {e_rad:.3e}"
488        );
489    }
490
491    #[test]
492    fn test_n_h_positive_and_scaling() {
493        let cosmo = Cosmology::default();
494        assert!(cosmo.n_h(0.0) > 0.0);
495        assert!(cosmo.n_h(1000.0) > cosmo.n_h(0.0));
496        // n_H scales as (1+z)³
497        let ratio = cosmo.n_h(1000.0) / cosmo.n_h(0.0);
498        let expected = 1001.0_f64.powi(3);
499        assert!(
500            (ratio / expected - 1.0).abs() < 1e-10,
501            "n_H should scale as (1+z)³: ratio = {ratio:.6e}, expected = {expected:.6e}"
502        );
503    }
504
505    #[test]
506    fn test_cosmic_time_order_and_magnitude() {
507        let cosmo = Cosmology::default();
508        let t1 = cosmo.cosmic_time(1000.0);
509        let t2 = cosmo.cosmic_time(100.0);
510        assert!(t2 > t1, "t(z=100) should be > t(z=1000)");
511        // t(z=0) ~ age of universe ~ 13.7 Gyr ≈ 4.3e17 s
512        let t0 = cosmo.cosmic_time(0.0);
513        assert!(
514            t0 > 3e17 && t0 < 5e17,
515            "t(z=0) = {t0:.3e} s, expected ~4.3e17 s (13.7 Gyr)"
516        );
517    }
518
519    #[test]
520    fn test_compton_y_parameter_high_z() {
521        let cosmo = Cosmology::default();
522        // At z=1e5, y_C should be moderate (limited by recombination at z~1100)
523        let yc = cosmo.compton_y_parameter(1e5);
524        assert!(yc > 0.1, "y_C(1e5) = {yc:.3e}, should be > 0.1");
525        // At z=1e6 (deep in fully ionized era), y_C should be large
526        let yc_deep = cosmo.compton_y_parameter(1e6);
527        assert!(
528            yc_deep > 10.0,
529            "y_C(1e6) = {yc_deep:.3e}, should be >> 1 (deep thermalization era)"
530        );
531    }
532
533    #[test]
534    fn test_compton_y_parameter_low_z() {
535        let cosmo = Cosmology::default();
536        // At z << 1100, y_C should be small (inefficient Comptonization)
537        let yc = cosmo.compton_y_parameter(500.0);
538        assert!(
539            yc < 0.1,
540            "y_C(500) = {yc:.3e}, should be << 1 (post-recombination)"
541        );
542    }
543
544    #[test]
545    fn test_compton_y_parameter_monotonic() {
546        let cosmo = Cosmology::default();
547        let y1 = cosmo.compton_y_parameter(500.0);
548        let y2 = cosmo.compton_y_parameter(2000.0);
549        let y3 = cosmo.compton_y_parameter(1e5);
550        assert!(
551            y3 > y2 && y2 > y1,
552            "y_C should be monotonically increasing: {y1:.3e} < {y2:.3e} < {y3:.3e}"
553        );
554    }
555
556    #[test]
557    fn test_baryon_photon_ratio() {
558        let cosmo = Cosmology::default();
559        // R = ρ_b / (4/3 ρ_γ), scales as 1/(1+z)
560        let r_0 = cosmo.baryon_photon_ratio(0.0);
561        assert!(
562            r_0 > 100.0,
563            "R(0) = {r_0:.1}, expected >> 1 (matter dominated today)"
564        );
565
566        let r_1100 = cosmo.baryon_photon_ratio(1100.0);
567        // At recombination, R ~ 0.6
568        assert!(
569            r_1100 > 0.1 && r_1100 < 2.0,
570            "R(1100) = {r_1100:.3}, expected ~0.6"
571        );
572
573        // Should scale as 1/(1+z)
574        let ratio = r_0 / r_1100;
575        assert!(
576            (ratio - 1101.0).abs() / 1101.0 < 0.01,
577            "R should scale as 1/(1+z)"
578        );
579    }
580
581    #[test]
582    fn test_density_accessors() {
583        let cosmo = Cosmology::default();
584        // Test various density accessors for physical sanity
585        assert!(cosmo.omega_gamma() > 0.0);
586        assert!(cosmo.omega_rel() > cosmo.omega_gamma()); // includes neutrinos
587        assert!(cosmo.omega_lambda() > 0.0);
588        assert!(cosmo.omega_cdm_frac() > 0.0);
589        assert!(cosmo.rho_gamma(0.0) > 0.0);
590        assert!(cosmo.n_he(0.0) > 0.0);
591        assert!(cosmo.n_e(0.0, 1.0) > 0.0);
592        assert!(cosmo.t_compton(1e4, 1.0) > 0.0);
593        assert!(cosmo.dt_dz(1e4) < 0.0); // dt/dz < 0 (time increases as z decreases)
594
595        // Quantitative checks against known values
596        // n_gamma(0) = (2ζ(3)/π²)(kT/ℏc)³ ≈ 4.1e8 /m³ for T=2.726 K
597        let n_gam = cosmo.n_gamma(0.0);
598        assert!(
599            (n_gam - 4.1e8).abs() / 4.1e8 < 0.02,
600            "n_gamma(0) = {n_gam:.3e}, expected ~4.1e8 /m³"
601        );
602        // n_H(0) = (1-Y_p) × 3H₀²Ω_b/(8πG m_p) ≈ 0.19 /m³ for default params
603        let n_h = cosmo.n_h(0.0);
604        assert!(
605            (n_h - 0.19).abs() / 0.19 < 0.05,
606            "n_H(0) = {n_h:.4}, expected ~0.19 /m³"
607        );
608    }
609
610    #[test]
611    fn test_planck2015_preset() {
612        let cosmo = Cosmology::planck2015();
613        assert!((cosmo.h - 0.6727).abs() < 0.001);
614        assert!((cosmo.omega_b - 0.02225).abs() < 1e-5);
615        assert!((cosmo.y_p - 0.2467).abs() < 1e-4);
616    }
617
618    #[test]
619    fn test_planck2018_preset() {
620        let cosmo = Cosmology::planck2018();
621        assert!(
622            (cosmo.h - 0.6736).abs() < 0.001,
623            "h={}, expected 0.6736",
624            cosmo.h
625        );
626        assert!(
627            (cosmo.omega_b - 0.02237).abs() < 1e-5,
628            "omega_b={}, expected 0.02237",
629            cosmo.omega_b
630        );
631        assert!(
632            (cosmo.omega_cdm - 0.1200).abs() < 1e-4,
633            "omega_cdm={}, expected 0.1200",
634            cosmo.omega_cdm
635        );
636        assert!(
637            (cosmo.t_cmb - 2.7255).abs() < 0.001,
638            "t_cmb={}, expected 2.7255",
639            cosmo.t_cmb
640        );
641        assert!(
642            (cosmo.y_p - 0.2454).abs() < 1e-4,
643            "y_p={}, expected 0.2454",
644            cosmo.y_p
645        );
646        assert!(
647            (cosmo.n_eff - 3.044).abs() < 0.01,
648            "n_eff={}, expected 3.044",
649            cosmo.n_eff
650        );
651        // Derived quantities should be valid
652        assert!(cosmo.hubble(0.0) > 0.0);
653        assert!(cosmo.n_h(0.0) > 0.0);
654    }
655
656    #[test]
657    fn test_cached_f_he() {
658        let cosmo = Cosmology::default();
659        let expected = cosmo.y_p / (4.0 * (1.0 - cosmo.y_p));
660        assert!((cosmo.f_he() - expected).abs() < 1e-15);
661    }
662
663    #[test]
664    fn test_cosmology_new_custom_params() {
665        // Verify that Cosmology::new() correctly computes all derived quantities
666        // from custom parameters (not just the defaults).
667        let cosmo = Cosmology::new(2.725, 0.022, 0.12, 0.67, 3.046, 0.245).unwrap();
668
669        // Check that stored params are correct
670        assert!((cosmo.t_cmb - 2.725).abs() < 1e-10);
671        assert!((cosmo.omega_b - 0.022).abs() < 1e-10);
672        assert!((cosmo.h - 0.67).abs() < 1e-10);
673        assert!((cosmo.y_p - 0.245).abs() < 1e-10);
674
675        // Verify derived f_he is recomputed (not stale from defaults)
676        let expected_f_he = 0.245 / (4.0 * (1.0 - 0.245));
677        assert!(
678            (cosmo.f_he() - expected_f_he).abs() < 1e-12,
679            "f_he = {}, expected {expected_f_he}",
680            cosmo.f_he()
681        );
682
683        // n_H(0) should reflect the custom omega_b and Y_p
684        let n_h_0 = cosmo.n_h(0.0);
685        assert!(n_h_0 > 0.0 && n_h_0.is_finite());
686
687        // Different omega_b should give different n_H
688        let cosmo2 = Cosmology::new(2.725, 0.044, 0.12, 0.67, 3.046, 0.245).unwrap();
689        let n_h_0_2 = cosmo2.n_h(0.0);
690        // omega_b doubled → n_H doubled
691        let ratio = n_h_0_2 / n_h_0;
692        assert!(
693            (ratio - 2.0).abs() < 0.01,
694            "Doubling omega_b should double n_H: ratio={ratio:.4}"
695        );
696
697        // Hubble rate should depend on h
698        let h_z0 = cosmo.hubble(0.0);
699        assert!(h_z0 > 0.0 && h_z0.is_finite());
700    }
701}