spectroxide/electron_temp.rs
1//! Electron temperature state (ρ_e = T_e/T_z).
2//!
3//! This module provides the [`ElectronTemperature`] struct used by the solver
4//! to hold the current T_e/T_z ratio. The production T_e update is performed
5//! in `solver::compute_hubble_coefficients` using a perturbative form of the
6//! Compton equilibrium:
7//!
8//! Δρ_eq = ΔI₄/(4 G₃) − ΔG₃/G₃ × (I₄/(4G₃))
9//!
10//! computed from Δn only. The full form ρ_eq = I₄/(4G₃) (below) has
11//! ~0.1% numerical error from near-cancellation that swamps the O(10⁻⁵)
12//! physical signal — do not use it in the solver. It is retained here only
13//! as a verification tool for off-path consistency checks and tests.
14//!
15//! References:
16//! - Chluba & Sunyaev (2012), MNRAS 419, 1294 [Eq. 15-18]
17
18use crate::spectrum::compton_equilibrium_ratio;
19
20/// State of the electron temperature solver.
21#[derive(Debug, Clone)]
22pub struct ElectronTemperature {
23 /// Current T_e/T_z ratio
24 pub rho_e: f64,
25}
26
27impl Default for ElectronTemperature {
28 fn default() -> Self {
29 ElectronTemperature { rho_e: 1.0 }
30 }
31}
32
33impl ElectronTemperature {
34 /// θ_e from a precomputed θ_z value (cosmology-aware).
35 ///
36 /// Pass `cosmo.theta_z(z)` so a non-default T_CMB is honoured.
37 #[inline]
38 pub fn theta_e_with(&self, theta_z_val: f64) -> f64 {
39 self.rho_e * theta_z_val
40 }
41
42 /// Set ρ_e from the full Compton-equilibrium form I₄/(4G₃).
43 ///
44 /// **Not used by the production solver.** The full form has ~0.1%
45 /// numerical error from near-cancellation of the two integrals, which
46 /// swamps the O(10⁻⁵) physical distortion signal. Retained only as a
47 /// reference for off-path tests and verification; the solver uses the
48 /// perturbative update in `solver::compute_hubble_coefficients` instead.
49 pub fn update_equilibrium(&mut self, x_grid: &[f64], n_full: &[f64]) {
50 self.rho_e = compton_equilibrium_ratio(x_grid, n_full);
51 }
52}
53
54#[cfg(test)]
55mod tests {
56 use super::*;
57 use crate::grid::FrequencyGrid;
58 use crate::spectrum::planck;
59
60 #[test]
61 fn test_equilibrium_for_planck() {
62 let grid = FrequencyGrid::log_uniform(1e-4, 50.0, 5000);
63 let n_pl: Vec<f64> = grid.x.iter().map(|&x| planck(x)).collect();
64
65 let mut te = ElectronTemperature::default();
66 te.update_equilibrium(&grid.x, &n_pl);
67
68 assert!(
69 (te.rho_e - 1.0).abs() < 1e-3,
70 "ρ_e = {}, expected 1.0 for Planck",
71 te.rho_e
72 );
73 }
74
75 // test_theta_e_with_scaling removed: theta_e_with(θ_z) is defined as
76 // rho_e * θ_z, so asserting (1.05 * θ_z).abs() < 1e-30 was tautological.
77
78 /// Verify ρ_e for a Bose-Einstein distribution with known μ.
79 ///
80 /// For n_BE(x, μ) = 1/(e^{x+μ}-1), the Compton equilibrium ratio is:
81 /// ρ_e = I₄/(4G₃) where I₄ = ∫x⁴ n(1+n)dx, G₃ = ∫x³ n dx.
82 /// For μ > 0: spectrum is harder than Planck → ρ_e > 1.
83 /// For μ < 0: spectrum is softer → ρ_e < 1.
84 #[test]
85 fn test_equilibrium_for_bose_einstein() {
86 let grid = FrequencyGrid::log_uniform(1e-4, 50.0, 10000);
87
88 // Only test positive μ: n_BE(x, μ) = 1/(e^{x+μ}-1) is well-defined for μ > 0.
89 // Negative μ causes a pole at x = |μ| which makes the integral diverge.
90 for &mu in &[1e-4, 1e-3, 5e-3] {
91 let n_be: Vec<f64> = grid
92 .x
93 .iter()
94 .map(|&x| 1.0 / ((x + mu).exp() - 1.0))
95 .collect();
96
97 let mut te = ElectronTemperature::default();
98 te.update_equilibrium(&grid.x, &n_be);
99
100 // Independent numerical integration (proper trapezoidal for n(1+n))
101 let mut g3 = 0.0;
102 let mut i4 = 0.0;
103 for i in 1..grid.n {
104 let dx = grid.x[i] - grid.x[i - 1];
105 let x_mid = 0.5 * (grid.x[i] + grid.x[i - 1]);
106 let n_mid = 0.5 * (n_be[i] + n_be[i - 1]);
107 let n_l = n_be[i - 1];
108 let n_r = n_be[i];
109 let nn1_mid = 0.5 * (n_l * (1.0 + n_l) + n_r * (1.0 + n_r));
110 g3 += x_mid.powi(3) * n_mid * dx;
111 i4 += x_mid.powi(4) * nn1_mid * dx;
112 }
113 let rho_expected = i4 / (4.0 * g3);
114
115 let rel = (te.rho_e - rho_expected).abs() / rho_expected;
116 assert!(
117 rel < 1e-6,
118 "μ={mu}: ρ_e={:.10} vs expected {rho_expected:.10}, err={rel:.2e}",
119 te.rho_e
120 );
121
122 // Physical direction check: μ > 0 → fewer low-x photons → harder spectrum → ρ_e > 1
123 assert!(
124 te.rho_e > 1.0,
125 "μ={mu}>0 should give ρ_e>1, got {:.10}",
126 te.rho_e
127 );
128
129 // Larger μ → more deviation from 1 (use tolerance; at small μ the signal is ~μ²)
130 if mu > 1e-4 {
131 let rho_small = {
132 let n_small: Vec<f64> = grid
133 .x
134 .iter()
135 .map(|&x| 1.0 / ((x + 1e-4).exp() - 1.0))
136 .collect();
137 crate::spectrum::compton_equilibrium_ratio(&grid.x, &n_small)
138 };
139 assert!(
140 te.rho_e >= rho_small - 1e-9,
141 "Larger μ should give larger ρ_e: μ={mu} → {:.10} < μ=1e-4 → {rho_small:.10}",
142 te.rho_e
143 );
144 }
145 }
146 }
147}