spectroxide/double_compton.rs
1//! Double Compton (DC) scattering: γ + e → γ + γ + e
2//!
3//! Photon-number-changing process that dominates at z > few × 10⁵.
4//! In the soft photon limit, DC emission creates low-frequency photons
5//! that drive the spectrum toward a Planck distribution.
6//!
7//! The DC emission coefficient:
8//! K_DC(x, θ_z, θ_e) = (4α/3π) θ_z² g_dc(x, θ_z, θ_e)
9//!
10//! where g_dc is the DC Gaunt factor including relativistic corrections.
11//!
12//! References:
13//! - Lightman (1981) — original DC coefficient
14//! - Chluba, Sazonov & Sunyaev (2007), A&A 468, 785 [arXiv:0705.3033]
15//! - Chluba & Sunyaev (2012), MNRAS 419, 1294 [Eq. 10-13]
16
17use crate::constants::*;
18use crate::spectrum::planck;
19
20/// DC Gaunt factor in the soft photon limit with relativistic corrections.
21///
22/// g_dc(x, θ_z, θ_e) ≈ (I₄^pl / (1 + 14.16 θ_z)) · H_dc(x)
23///
24/// where I₄^pl = 4π⁴/15 and H_dc is the high-frequency suppression.
25///
26/// The factor (1 + 14.16 θ_z)⁻¹ is the leading-order relativistic correction
27/// from thermal averaging (Chluba+ 2007), evaluated at the **photon
28/// temperature** θ_z. Strictly the thermal average runs over the electron
29/// distribution, so θ_e would be the cleaner choice when T_e ≠ T_z
30/// (injection-driven heating); however CS2012 parametrises DC through θ_z
31/// and the difference is <1% for θ_z ≲ 10⁻³ even when |ρ_e−1| ~ 0.1.
32pub fn dc_gaunt_factor(x: f64, theta_z: f64) -> f64 {
33 let i4_pl = I4_PLANCK;
34 let relativistic_correction = 1.0 / (1.0 + 14.16 * theta_z);
35 let h_dc = dc_high_freq_suppression(x);
36 i4_pl * relativistic_correction * h_dc
37}
38
39/// High-frequency suppression factor for DC emission.
40///
41/// H_dc^pl(x) ≈ exp(-2x) [1 + 3x/2 + 29x²/24 + 11x³/16 + 5x⁴/12]
42///
43/// This accounts for the suppression of DC emission at high photon energies
44/// where the soft-photon approximation breaks down.
45///
46/// Reference: Chluba & Sunyaev (2012), Eq. 13.
47pub fn dc_high_freq_suppression(x: f64) -> f64 {
48 if x > 100.0 {
49 return 0.0;
50 }
51 (-2.0 * x).exp() * (1.0 + x * (1.5 + x * (29.0 / 24.0 + x * (11.0 / 16.0 + x * (5.0 / 12.0)))))
52}
53
54/// DC emission coefficient K_DC(x, θ_z, θ_e).
55///
56/// K_DC = (4α/3π) θ_z² g_dc(x, θ_z, θ_e)
57///
58/// The emission/absorption term in the photon equation is:
59/// dn/dτ|_DC = (K_DC / x³) [n_eq - n]
60///
61/// where n_eq is the equilibrium distribution (Planck at the electron temperature).
62pub fn dc_emission_coefficient(x: f64, theta_z: f64) -> f64 {
63 4.0 * ALPHA_FS / (3.0 * std::f64::consts::PI) * theta_z * theta_z * dc_gaunt_factor(x, theta_z)
64}
65
66/// Precompute the x-independent DC prefactor: (4α/3π) θ_z² × I₄^pl / (1 + 14.16 θ_z)
67///
68/// The only x-dependent part remaining is H_dc(x) = exp(-2x) × polynomial.
69pub fn dc_prefactor(theta_z: f64) -> f64 {
70 4.0 * ALPHA_FS / (3.0 * std::f64::consts::PI) * theta_z * theta_z * I4_PLANCK
71 / (1.0 + 14.16 * theta_z)
72}
73
74/// Fast DC emission coefficient using precomputed x-independent prefactor.
75#[inline]
76pub fn dc_emission_coefficient_fast(x: f64, dc_pre: f64) -> f64 {
77 dc_pre * dc_high_freq_suppression(x)
78}
79
80/// Compute the DC contribution to the photon equation RHS (test-only).
81///
82/// Production code uses the coupled inplace solver with precomputed rates.
83///
84/// **Do not promote this to production.** The naive `(e^{x_e} − 1)` form used
85/// here loses precision as `|ρ_e − 1| → 0` because the source term subtracts
86/// two nearly equal numbers. `solver.rs::compute_emission_rates` switches to
87/// the analytical Taylor expansion `x(ρ_e−1)/ρ_e · n_pl(1+n_pl)` when
88/// `|ρ_e − 1| < 0.01` to avoid this. Any caller that evaluates the DC source
89/// at the sub-percent T_e deviations typical of post-recombination physics
90/// needs the same Taylor branch.
91#[cfg(test)]
92pub fn dc_rhs(x: &[f64], delta_n: &[f64], theta_z: f64, theta_e: f64) -> Vec<f64> {
93 let n = x.len();
94 let phi = theta_z / theta_e;
95 let mut rhs = vec![0.0; n];
96
97 for i in 0..n {
98 let xi = x[i];
99 let k_dc = dc_emission_coefficient(xi, theta_z);
100 let x_e = xi * phi; // frequency at electron temperature
101
102 // Chluba & Sunyaev (2012) Eq. (8):
103 // dn/dτ|_DC = (K_DC/x³) [1 - n(e^{x_e} - 1)]
104 let n_current = planck(xi) + delta_n[i];
105 rhs[i] = k_dc / xi.powi(3) * (1.0 - n_current * x_e.exp_m1());
106 }
107
108 rhs
109}
110
111/// DC emission rate integrated over frequency (for electron temperature equation).
112///
113/// H_DC = (1/(4 G₃ θ_z)) ∫ [1 − n(e^{x_e} − 1)] K_DC(x)/x³ · x³ dx
114///
115/// This enters the electron temperature evolution as a cooling term.
116pub fn dc_heating_integral(x_grid: &[f64], delta_n: &[f64], theta_z: f64, theta_e: f64) -> f64 {
117 let phi = theta_z / theta_e;
118 let mut integral = 0.0;
119
120 for i in 1..x_grid.len() {
121 let dx = x_grid[i] - x_grid[i - 1];
122 let x_mid = 0.5 * (x_grid[i] + x_grid[i - 1]);
123 let dn_mid = 0.5 * (delta_n[i] + delta_n[i - 1]);
124 let n_mid = planck(x_mid) + dn_mid;
125 let x_e = x_mid * phi;
126
127 let k_dc = dc_emission_coefficient(x_mid, theta_z);
128 // Integrand: [1 - n (e^{x_e} - 1)] K_DC dx
129 let factor = 1.0 - n_mid * x_e.exp_m1();
130 integral += factor * k_dc * dx;
131 }
132
133 integral / (4.0 * G3_PLANCK * theta_z)
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 #[test]
141 fn test_dc_suppression_low_x() {
142 // H_dc(0) = 1
143 assert!((dc_high_freq_suppression(0.0) - 1.0).abs() < 1e-14);
144 }
145
146 #[test]
147 fn test_dc_suppression_high_x() {
148 // H_dc should vanish for large x
149 assert!(dc_high_freq_suppression(50.0) < 1e-20);
150 }
151
152 #[test]
153 fn test_dc_coefficient_positive() {
154 let theta_z = 4.6e-4; // z ≈ 10^6
155 for &x in &[0.001, 0.01, 0.1, 1.0, 5.0] {
156 assert!(
157 dc_emission_coefficient(x, theta_z) >= 0.0,
158 "K_DC should be non-negative at x={x}"
159 );
160 }
161 }
162
163 #[test]
164 fn test_dc_rhs_zero_for_planck() {
165 // For Planck spectrum with T_e = T_z, DC should produce no change
166 let x: Vec<f64> = (0..100).map(|i| 0.01 + 0.3 * i as f64).collect();
167 let delta_n = vec![0.0; x.len()];
168 let theta = 4.6e-4;
169
170 let rhs = dc_rhs(&x, &delta_n, theta, theta);
171 let max_rhs: f64 = rhs
172 .iter()
173 .map(|v| v.abs())
174 .fold(0.0, |a, b| if b.is_nan() { f64::NAN } else { a.max(b) });
175 // The [1 - n(e^x - 1)] form has O(ε) cancellation, so tolerance is machine epsilon
176 assert!(
177 max_rhs < 1e-12,
178 "DC RHS should be zero for Planck spectrum: max = {max_rhs}"
179 );
180 }
181
182 #[test]
183 fn test_dc_gaunt_factor_literature_values() {
184 // At theta_z = 0 (non-relativistic limit):
185 // g_dc(x, 0) = I4_pl * 1.0 * H_dc(x)
186 // At x=0: H_dc(0) = 1.0, so g_dc = I4_pl = 4 * G3 = 4 * pi^4/15
187 let i4_pl = I4_PLANCK;
188 let g_at_zero = dc_gaunt_factor(0.0, 0.0);
189 assert!(
190 (g_at_zero - i4_pl).abs() < 1e-10,
191 "g_dc(0,0) should be I4_pl = {i4_pl}, got {g_at_zero}"
192 );
193
194 // At x=10: H_dc(10) = e^{-20} × (1 + 15 + 29×100/24 + 11×1000/16 + 5×10000/12)
195 // Manually computed from the polynomial definition (Chluba & Sunyaev 2012, Eq. 13):
196 let h_dc_10_manual = (-20.0_f64).exp()
197 * (1.0 + 15.0 + 29.0 * 100.0 / 24.0 + 11.0 * 1000.0 / 16.0 + 5.0 * 10000.0 / 12.0);
198 // h_dc_10 ≈ 2.061e-6 × 4991.0 ≈ 0.01029
199 let g_at_10 = dc_gaunt_factor(10.0, 0.0);
200 let expected = i4_pl * h_dc_10_manual;
201 assert!(
202 (g_at_10 - expected).abs() / expected < 1e-10,
203 "g_dc(10,0) = {g_at_10:.6e}, expected {expected:.6e} (manually computed)"
204 );
205 // Verify strong suppression: g(10)/g(0) ≈ H_dc(10) ≈ 0.01
206 assert!(
207 g_at_10 < 0.02 * g_at_zero,
208 "g_dc at x=10 should be strongly suppressed: {g_at_10} vs {g_at_zero}"
209 );
210 }
211
212 #[test]
213 fn test_dc_high_freq_suppression_extremes() {
214 // x=200 (very high) → exactly 0.0 due to the x>100 guard
215 assert_eq!(dc_high_freq_suppression(200.0), 0.0);
216
217 // x=0.01 (very low) → approximately 1.0
218 let h = dc_high_freq_suppression(0.01);
219 assert!(
220 (h - 1.0).abs() < 0.05,
221 "H_dc(0.01) should be close to 1.0, got {h}"
222 );
223
224 // More precisely: exp(-0.02) * (1 + 0.015 + ...) ≈ 0.9802 * 1.015 ≈ 0.995
225 assert!(
226 h > 0.99 && h < 1.0,
227 "H_dc(0.01) should be in (0.99, 1.0), got {h}"
228 );
229 }
230
231 /// DC Gaunt factor at moderate x: verify H_dc against manually computed polynomial.
232 /// H_dc(x) = e^{-2x} (1 + 3x/2 + 29x²/24 + 11x³/16 + 5x⁴/12)
233 #[test]
234 fn test_dc_gaunt_factor_at_specific_x_values() {
235 let i4_pl = I4_PLANCK;
236
237 // x=1: H_dc(1) = e^{-2} × (1 + 1.5 + 29/24 + 11/16 + 5/12)
238 // = e^{-2} × (1 + 1.5 + 1.20833 + 0.6875 + 0.41667)
239 // = e^{-2} × 4.8125 ≈ 0.1353 × 4.8125 ≈ 0.6512
240 let h_dc_1 = (-2.0_f64).exp() * (1.0 + 1.5 + 29.0 / 24.0 + 11.0 / 16.0 + 5.0 / 12.0);
241 let g1 = dc_gaunt_factor(1.0, 0.0);
242 let expected_g1 = i4_pl * h_dc_1;
243 assert!(
244 (g1 - expected_g1).abs() / expected_g1 < 1e-12,
245 "g_dc(1,0) = {g1:.6e}, expected {expected_g1:.6e}"
246 );
247
248 // x=5: suppressed by e^{-10} × polynomial
249 // H_dc(5) = e^{-10} × (1 + 7.5 + 29×25/24 + 11×125/16 + 5×625/12)
250 // = e^{-10} × (1 + 7.5 + 30.21 + 85.94 + 260.42)
251 // = 4.54e-5 × 385.07 ≈ 0.0175
252 let g5 = dc_gaunt_factor(5.0, 0.0);
253 assert!(
254 g5 < 0.05 * i4_pl,
255 "g_dc(5,0) = {g5:.6e} should be < 5% of g_dc(0,0) = {i4_pl:.6e}"
256 );
257 }
258
259 #[test]
260 fn test_dc_drives_to_equilibrium() {
261 // With a positive distortion, DC should drive it toward zero (absorption)
262 let x: Vec<f64> = (0..100).map(|i| 0.01 + 0.1 * i as f64).collect();
263 // Positive distortion at low x
264 let delta_n: Vec<f64> = x
265 .iter()
266 .map(|&xi| if xi < 1.0 { 1e-4 } else { 0.0 })
267 .collect();
268 let theta = 4.6e-4;
269
270 let rhs = dc_rhs(&x, &delta_n, theta, theta);
271 // At low x where Δn > 0, RHS should be negative (absorption)
272 for (i, &xi) in x.iter().enumerate() {
273 if xi < 0.5 && delta_n[i] > 0.0 {
274 assert!(
275 rhs[i] < 0.0,
276 "DC should absorb excess photons at x={xi}: RHS = {}",
277 rhs[i]
278 );
279 }
280 }
281 }
282
283 /// Verify K_DC has physically reasonable absolute magnitude.
284 ///
285 /// At θ_z = 4.60e-5 (z ~ 10^5), x = 1:
286 /// K_DC = (4α/3π) θ_z² I₄^pl H_dc(1) / (1 + 14.16 θ_z)
287 /// ≈ 3.10e-3 × 2.12e-9 × 25.98 × 0.558
288 /// ≈ 9.5e-11
289 ///
290 /// This catches formula errors (missing factors, wrong powers of θ_z).
291 #[test]
292 fn test_dc_emission_coefficient_magnitude() {
293 let theta = 4.60e-5; // θ_z at z ~ 10^5
294 let x = 1.0;
295
296 let k_dc = dc_emission_coefficient(x, theta);
297 eprintln!("K_DC(x=1, θ_z=4.60e-5) = {k_dc:.4e}");
298
299 // Hand calculation:
300 let four_alpha_3pi = 4.0 * ALPHA_FS / (3.0 * std::f64::consts::PI);
301 let theta_sq = theta * theta;
302 let i4 = I4_PLANCK;
303 let h_dc_1 = (-2.0_f64).exp() * (1.0 + 1.5 + 29.0 / 24.0 + 11.0 / 16.0 + 5.0 / 12.0);
304 let rel_corr = 1.0 / (1.0 + 14.16 * theta);
305 let hand = four_alpha_3pi * theta_sq * i4 * h_dc_1 * rel_corr;
306 eprintln!(" Hand estimate = {hand:.4e}");
307 eprintln!(
308 " Components: 4α/3π={four_alpha_3pi:.4e}, θ²={theta_sq:.4e}, \
309 I4={i4:.4}, H_dc(1)={h_dc_1:.4}, rel_corr={rel_corr:.6}"
310 );
311
312 // Must be positive
313 assert!(k_dc > 0.0, "K_DC must be positive");
314
315 // Order of magnitude: should be ~10^{-11}, not wildly different
316 assert!(
317 k_dc > 1e-14 && k_dc < 1e-7,
318 "K_DC = {k_dc:.4e} outside physically reasonable range [1e-14, 1e-7]"
319 );
320
321 // Match hand calculation to within 10%
322 let rel_err = (k_dc - hand).abs() / hand;
323 assert!(
324 rel_err < 0.10,
325 "K_DC = {k_dc:.4e} differs from hand estimate {hand:.4e} by {rel_err:.2e}"
326 );
327 }
328
329 /// Verify H_dc polynomial coefficients match Chluba & Sunyaev (2012) Eq. 13.
330 ///
331 /// H_dc^pl(x) = exp(-2x) [1 + 3x/2 + 29x²/24 + 11x³/16 + 5x⁴/12]
332 ///
333 /// The Horner-form code computes this as:
334 /// exp(-2x) * (1 + x*(1.5 + x*(29/24 + x*(11/16 + x*(5/12)))))
335 ///
336 /// We verify both forms agree and check specific values.
337 #[test]
338 fn test_dc_polynomial_coefficients_cs2012() {
339 // Coefficients from CS2012 Eq. 13
340 let c0 = 1.0;
341 let c1 = 3.0 / 2.0;
342 let c2 = 29.0 / 24.0;
343 let c3 = 11.0 / 16.0;
344 let c4 = 5.0 / 12.0;
345
346 for &x in &[0.5_f64, 1.0, 2.0, 5.0] {
347 // Expanded form (independent computation)
348 let poly = c0 + c1 * x + c2 * x * x + c3 * x * x * x + c4 * x * x * x * x;
349 let h_expanded = (-2.0 * x).exp() * poly;
350
351 // Code's Horner form
352 let h_code = dc_high_freq_suppression(x);
353
354 let rel_err = (h_code - h_expanded).abs() / h_expanded.max(1e-30);
355 assert!(
356 rel_err < 1e-14,
357 "H_dc({x}): code={h_code:.6e}, expanded={h_expanded:.6e}, err={rel_err:.2e}"
358 );
359 }
360
361 // Verify H_dc(0) = 1 exactly (all terms vanish except c0)
362 let h0 = dc_high_freq_suppression(0.0);
363 assert!((h0 - 1.0).abs() < 1e-15, "H_dc(0) = {h0}, expected 1.0");
364
365 // Verify specific numerical values for regression
366 // H_dc(0.5) = exp(-1) * (1 + 0.75 + 0.3021 + 0.0859 + 0.0260) = 0.3679 * 2.1640 ≈ 0.796
367 let h_half = dc_high_freq_suppression(0.5);
368 let expected_half = (-1.0_f64).exp()
369 * (1.0 + 0.75 + 29.0 / 24.0 * 0.25 + 11.0 / 16.0 * 0.125 + 5.0 / 12.0 * 0.0625);
370 assert!(
371 (h_half - expected_half).abs() < 1e-14,
372 "H_dc(0.5) = {h_half:.6e}, expected {expected_half:.6e}"
373 );
374 }
375}