1use crate::constants::*;
19use crate::spectrum::planck;
20
21const BR_PREFACTOR: f64 = ALPHA_FS * LAMBDA_ELECTRON * LAMBDA_ELECTRON * LAMBDA_ELECTRON
24 / (2.0 * std::f64::consts::PI * 4.341_607_527_349_605_5);
25const S3_PI: f64 = 0.5513_2889_5421_7921;
29
30const LN_2_25: f64 = 0.8109_3021_6216_3288;
32
33const LN_1_125: f64 = 0.1177_8303_5656_3834;
35
36pub fn gaunt_ff_nr(x: f64, theta_e: f64, z_charge: f64) -> f64 {
52 if theta_e < 1e-30 {
53 return 1.0;
54 }
55 gaunt_ff_nr_fast(x, z_charge, 0.5 * theta_e.ln())
56}
57
58#[inline]
60fn gaunt_ff_nr_fast(x: f64, z_charge: f64, half_ln_theta_e: f64) -> f64 {
61 if x < 1e-30 {
62 return 1.0;
63 }
64 let arg = S3_PI * ((2.25 / (x * z_charge)).ln() + half_ln_theta_e) + 1.425;
65 1.0 + softplus(arg)
66}
67
68#[inline]
73fn gaunt_ff_nr_fast_preln(ln_x: f64, z_charge: f64, half_ln_theta_e: f64) -> f64 {
74 if ln_x < -69.0 {
76 return 1.0;
77 }
78 let ln_2_25_over_z = if z_charge < 1.5 { LN_2_25 } else { LN_1_125 };
79 let arg = S3_PI * (ln_2_25_over_z - ln_x + half_ln_theta_e) + 1.425;
80 1.0 + softplus(arg)
81}
82
83#[inline]
90fn softplus(arg: f64) -> f64 {
91 if arg > 20.0 {
92 arg
93 } else if arg < -20.0 {
94 arg.exp()
95 } else {
96 (1.0 + arg.exp()).ln()
97 }
98}
99
100pub fn br_emission_coefficient(
121 x: f64,
122 theta_e: f64,
123 theta_z: f64,
124 n_h: f64,
125 n_he: f64,
126 n_e: f64,
127 x_e_frac: f64,
128 cosmo: &crate::cosmology::Cosmology,
129) -> f64 {
130 if theta_e < 1e-30 || n_e < 1e-30 {
131 return 0.0;
132 }
133
134 let phi = theta_z / theta_e;
135
136 let temp_factor = theta_e.powf(-3.5) * (-x * phi).exp() / phi.powi(3);
138
139 let g_z1 = gaunt_ff_nr(x, theta_e, 1.0);
144 let g_z2 = gaunt_ff_nr(x, theta_e, 2.0);
145
146 let t_rad = theta_z * M_E_C2 / K_BOLTZMANN;
161 let z_approx = (t_rad / cosmo.t_cmb - 1.0).max(0.0);
162
163 let y_he_ii = crate::recombination::saha_he_ii(z_approx, cosmo);
164 let y_he_i = crate::recombination::saha_he_i(z_approx, cosmo);
165
166 let n_hii = x_e_frac.min(1.0) * n_h;
167 let n_heiii = y_he_ii * n_he;
168 let n_heii = (y_he_i - y_he_ii).max(0.0) * n_he;
169
170 let species_sum = n_hii * g_z1 + 4.0 * n_heiii * g_z2 + n_heii * g_z1;
171
172 BR_PREFACTOR * temp_factor * species_sum
173}
174
175pub fn br_emission_coefficient_with_he(
180 x: f64,
181 theta_e: f64,
182 theta_z: f64,
183 n_h: f64,
184 n_he: f64,
185 n_e: f64,
186 x_e_frac: f64,
187 y_he_ii: f64,
188 y_he_i: f64,
189) -> f64 {
190 if theta_e < 1e-30 || n_e < 1e-30 {
191 return 0.0;
192 }
193
194 let phi = theta_z / theta_e;
195 let temp_factor = theta_e.powf(-3.5) * (-x * phi).exp() / phi.powi(3);
196
197 let g_z1 = gaunt_ff_nr(x, theta_e, 1.0);
199 let g_he2 = gaunt_ff_nr(x, theta_e, 2.0);
200
201 let n_hii = x_e_frac.min(1.0) * n_h;
202 let n_heiii = y_he_ii * n_he;
203 let n_heii = (y_he_i - y_he_ii).max(0.0) * n_he;
204
205 let species_sum = n_hii * g_z1 + 4.0 * n_heiii * g_he2 + n_heii * g_z1;
206
207 BR_PREFACTOR * temp_factor * species_sum
208}
209
210pub struct BrPrecomputed {
216 pub base_factor: f64,
218 pub phi: f64,
220 pub n_hii: f64,
221 pub n_heiii: f64,
222 pub n_heii: f64,
223 pub half_ln_theta_e: f64,
225}
226
227pub fn br_precompute(
229 theta_e: f64,
230 theta_z: f64,
231 n_h: f64,
232 n_he: f64,
233 n_e: f64,
234 x_e_frac: f64,
235 y_he_ii: f64,
236 y_he_i: f64,
237) -> Option<BrPrecomputed> {
238 if theta_e < 1e-30 || n_e < 1e-30 {
239 return None;
240 }
241 let phi = theta_z / theta_e;
242 let base_factor = BR_PREFACTOR * theta_e.powf(-3.5) / (phi * phi * phi);
243 Some(BrPrecomputed {
244 base_factor,
245 phi,
246 n_hii: x_e_frac.min(1.0) * n_h,
247 n_heiii: y_he_ii * n_he,
248 n_heii: (y_he_i - y_he_ii).max(0.0) * n_he,
249 half_ln_theta_e: 0.5 * theta_e.ln(),
250 })
251}
252
253#[inline]
257pub fn br_emission_coefficient_fast(x: f64, pre: &BrPrecomputed) -> f64 {
258 let exp_xphi = (-x * pre.phi).exp();
259 let g_z1 = gaunt_ff_nr_fast(x, 1.0, pre.half_ln_theta_e);
261 let g_he2 = gaunt_ff_nr_fast(x, 2.0, pre.half_ln_theta_e);
262
263 let species_sum = pre.n_hii * g_z1 + 4.0 * pre.n_heiii * g_he2 + pre.n_heii * g_z1;
264
265 pre.base_factor * exp_xphi * species_sum
266}
267
268#[inline]
273pub fn br_emission_coefficient_fast_preln(x: f64, ln_x: f64, pre: &BrPrecomputed) -> f64 {
274 let exp_xphi = (-x * pre.phi).exp();
275 let g_z1 = gaunt_ff_nr_fast_preln(ln_x, 1.0, pre.half_ln_theta_e);
276 let g_he2 = gaunt_ff_nr_fast_preln(ln_x, 2.0, pre.half_ln_theta_e);
277
278 let species_sum = pre.n_hii * g_z1 + 4.0 * pre.n_heiii * g_he2 + pre.n_heii * g_z1;
279
280 pre.base_factor * exp_xphi * species_sum
281}
282
283pub fn br_heating_integral(
291 x_grid: &[f64],
292 delta_n: &[f64],
293 theta_z: f64,
294 theta_e: f64,
295 n_h: f64,
296 n_he: f64,
297 n_e: f64,
298 x_e_frac: f64,
299 y_he_ii: f64,
300 y_he_i: f64,
301) -> f64 {
302 if theta_e < 1e-30 || n_e < 1e-30 {
303 return 0.0;
304 }
305 let phi = theta_z / theta_e;
306 let mut integral = 0.0;
307
308 for i in 1..x_grid.len() {
309 let dx = x_grid[i] - x_grid[i - 1];
310 let x_mid = 0.5 * (x_grid[i] + x_grid[i - 1]);
311 let dn_mid = 0.5 * (delta_n[i] + delta_n[i - 1]);
312 let n_mid = planck(x_mid) + dn_mid;
313 let x_e = x_mid * phi;
314
315 let k_br = br_emission_coefficient_with_he(
316 x_mid, theta_e, theta_z, n_h, n_he, n_e, x_e_frac, y_he_ii, y_he_i,
317 );
318 let factor = 1.0 - n_mid * x_e.exp_m1();
320 integral += factor * k_br * dx;
321 }
322
323 integral / (4.0 * crate::constants::G3_PLANCK * theta_z)
324}
325
326#[cfg(test)]
335pub fn br_rhs(
336 x_grid: &[f64],
337 delta_n: &[f64],
338 theta_z: f64,
339 theta_e: f64,
340 n_h: f64,
341 n_he: f64,
342 n_e: f64,
343 x_e_frac: f64,
344 cosmo: &crate::cosmology::Cosmology,
345) -> Vec<f64> {
346 let n = x_grid.len();
347 let phi = theta_z / theta_e;
348 let mut rhs = vec![0.0; n];
349
350 for i in 0..n {
351 let xi = x_grid[i];
352 let k_br = br_emission_coefficient(xi, theta_e, theta_z, n_h, n_he, n_e, x_e_frac, cosmo);
353 let x_e = xi * phi; let n_current = planck(xi) + delta_n[i];
358 rhs[i] = k_br / xi.powi(3) * (1.0 - n_current * x_e.exp_m1());
359 }
360
361 rhs
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn test_gaunt_ff_positive_and_physical() {
370 assert!(gaunt_ff_nr(0.1, 1e-5, 1.0) > 0.0);
371 assert!(gaunt_ff_nr(1.0, 1e-4, 1.0) > 0.0);
372 assert!(gaunt_ff_nr(0.001, 1e-6, 1.0) > 0.0);
373 assert!(gaunt_ff_nr(0.1, 1e-5, 2.0) > 0.0);
375 let g1 = gaunt_ff_nr(1.0, 1e-5, 1.0);
377 let g2 = gaunt_ff_nr(1.0, 1e-5, 2.0);
378 assert!(g1 != g2, "Z=1 and Z=2 Gaunt factors should differ");
379
380 for &x in &[0.001, 0.01, 0.1, 1.0, 10.0] {
383 for &theta in &[1e-6, 1e-5, 1e-4, 1e-3] {
384 let g = gaunt_ff_nr(x, theta, 1.0);
385 assert!(g >= 1.0, "g_ff(x={x}, θ={theta}) = {g} < 1.0");
386 }
387 }
388
389 let g_lowx = gaunt_ff_nr(1e-4, 1e-4, 1.0);
392 assert!(
393 g_lowx > 3.0,
394 "Gaunt factor at very low x should be > 3 (classical limit): got {g_lowx:.2}"
395 );
396
397 let g_highx = gaunt_ff_nr(1.0, 1e-4, 1.0);
399 assert!(
400 g_lowx > g_highx,
401 "Gaunt factor should decrease with x: g(1e-4)={g_lowx:.2}, g(1)={g_highx:.2}"
402 );
403 }
404
405 #[test]
406 fn test_br_coefficient_positive() {
407 let theta_e = 1e-5;
408 let theta_z = theta_e;
409 let n_h = 1e6; let n_he = 0.08 * n_h;
411 let n_e = n_h;
412 let x_e = 1.0;
413 let cosmo = crate::cosmology::Cosmology::default();
414
415 for &x in &[0.001, 0.01, 0.1, 1.0] {
416 let k = br_emission_coefficient(x, theta_e, theta_z, n_h, n_he, n_e, x_e, &cosmo);
417 assert!(k >= 0.0, "K_BR should be non-negative at x={x}: K={k}");
418 }
419 }
420
421 #[test]
422 fn test_br_rhs_zero_for_planck() {
423 let x: Vec<f64> = (1..100).map(|i| 0.01 * i as f64).collect();
424 let delta_n = vec![0.0; x.len()];
425 let theta = 1e-5;
426 let n_h = 1e6;
427 let n_he = 0.08 * n_h;
428 let n_e = n_h;
429 let cosmo = crate::cosmology::Cosmology::default();
430
431 let rhs = br_rhs(&x, &delta_n, theta, theta, n_h, n_he, n_e, 1.0, &cosmo);
432 let max_rhs: f64 = rhs
433 .iter()
434 .map(|v| v.abs())
435 .fold(0.0, |a, b| if b.is_nan() { f64::NAN } else { a.max(b) });
436 assert!(
437 max_rhs < 1e-20,
438 "BR RHS should be zero for Planck with T_e=T_z: max = {max_rhs}"
439 );
440 }
441
442 #[test]
443 fn test_br_heating_integral_zero_for_planck() {
444 let x: Vec<f64> = (1..500).map(|i| 0.001 + 0.06 * i as f64).collect();
446 let delta_n = vec![0.0; x.len()];
447 let theta = 4.6e-4; let n_h = 1e12;
449 let n_he = 0.08 * n_h;
450 let n_e = n_h;
451
452 let h_br = br_heating_integral(&x, &delta_n, theta, theta, n_h, n_he, n_e, 1.0, 1.0, 1.0);
453 assert!(
454 h_br.abs() < 1e-10,
455 "BR heating integral should be ~0 for Planck: H_BR = {h_br}"
456 );
457 }
458
459 #[test]
460 fn test_br_rhs_nonzero_for_te_ne_tz() {
461 let x: Vec<f64> = (1..100).map(|i| 0.01 * i as f64).collect();
465 let delta_n = vec![0.0; x.len()];
466 let theta_e = 1.001e-5; let theta_z = 1e-5;
468 let n_h = 1e6;
469 let n_he = 0.08 * n_h;
470 let n_e = n_h;
471 let cosmo = crate::cosmology::Cosmology::default();
472
473 let rhs = br_rhs(&x, &delta_n, theta_z, theta_e, n_h, n_he, n_e, 1.0, &cosmo);
474
475 assert!(
477 rhs[1] > 0.0,
478 "BR RHS should be positive at low x when T_e > T_z: rhs[1] = {}",
479 rhs[1]
480 );
481
482 let dc_rhs_vals = crate::double_compton::dc_rhs(&x, &delta_n, theta_z, theta_e);
485 assert!(
486 dc_rhs_vals[1] > 0.0,
487 "DC RHS should also be positive at low x when T_e > T_z"
488 );
489 }
490
491 #[test]
492 fn test_softplus_all_branches() {
493 let sp_0 = softplus(0.0);
495 assert!((sp_0 - (2.0_f64).ln()).abs() < 1e-14, "softplus(0) = ln(2)");
496
497 let sp_high = softplus(25.0);
499 assert!(
500 (sp_high - 25.0).abs() < 1e-8,
501 "softplus(25) ≈ 25: got {sp_high}"
502 );
503
504 let sp_low = softplus(-25.0);
506 let expected = (-25.0_f64).exp();
507 assert!(
508 (sp_low - expected).abs() < 1e-20,
509 "softplus(-25) ≈ exp(-25)"
510 );
511 }
512
513 #[test]
518 fn test_br_fast_variants_match_reference() {
519 let cases: Vec<(f64, f64, f64, f64, f64, f64, f64)> = vec![
520 (0.001, 1e-5, 1e-5, 1e6, 8e4, 1e6, 1.0),
522 (0.01, 1e-5, 1e-5, 1e6, 8e4, 1e6, 1.0),
523 (0.1, 1e-4, 9e-5, 1e8, 8e6, 1.1e8, 1.1),
524 (1.0, 5e-6, 5e-6, 1e10, 8e8, 1e10, 1.0),
525 (10.0, 1e-3, 1e-3, 1e12, 8e10, 1e12, 1.0),
526 ];
527
528 let cosmo = crate::cosmology::Cosmology::default();
529
530 for &(x, theta_e, theta_z, n_h, n_he, n_e, x_e_frac) in &cases {
531 let y_he_ii = 0.9;
532 let y_he_i = 1.0;
533
534 let k_ref = br_emission_coefficient_with_he(
536 x, theta_e, theta_z, n_h, n_he, n_e, x_e_frac, y_he_ii, y_he_i,
537 );
538
539 let pre =
541 br_precompute(theta_e, theta_z, n_h, n_he, n_e, x_e_frac, y_he_ii, y_he_i).unwrap();
542 let k_fast = br_emission_coefficient_fast(x, &pre);
543 let rel_fast = (k_fast - k_ref).abs() / k_ref.max(1e-30);
544 assert!(
545 rel_fast < 1e-10,
546 "fast mismatch at x={x}: ref={k_ref}, fast={k_fast}, rel={rel_fast}"
547 );
548
549 let k_preln = br_emission_coefficient_fast_preln(x, x.ln(), &pre);
551 let rel_preln = (k_preln - k_ref).abs() / k_ref.max(1e-30);
552 assert!(
553 rel_preln < 1e-10,
554 "fast_preln mismatch at x={x}: ref={k_ref}, preln={k_preln}, rel={rel_preln}"
555 );
556 }
557
558 let theta_e = 1e-5;
561 let theta_z = theta_e;
562 let n_h = 1e6;
563 let n_he = 0.08 * n_h;
564 let n_e = n_h;
565 let x_e = 1.0;
566 let z_approx = (theta_z / crate::constants::theta_z(0.0) - 1.0).max(100.0);
567 let y_he_i = crate::recombination::saha_he_i(z_approx, &cosmo);
568 let y_he_ii = crate::recombination::saha_he_ii(z_approx, &cosmo);
569
570 let k_saha = br_emission_coefficient(0.1, theta_e, theta_z, n_h, n_he, n_e, x_e, &cosmo);
571 let k_he = br_emission_coefficient_with_he(
572 0.1, theta_e, theta_z, n_h, n_he, n_e, x_e, y_he_ii, y_he_i,
573 );
574 let rel = (k_he - k_saha).abs() / k_saha.max(1e-30);
575 assert!(
576 rel < 1e-10,
577 "with_he vs saha mismatch: saha={k_saha}, he={k_he}, rel={rel}"
578 );
579 }
580
581 #[test]
582 fn test_br_precompute_none_for_zero_theta() {
583 let pre = br_precompute(0.0, 1e-5, 1e6, 1e4, 1e6, 1.0, 0.0, 1.0);
584 assert!(pre.is_none(), "Should return None for theta_e = 0");
585 }
586
587 #[test]
588 fn test_br_precompute_returns_none_for_tiny_inputs() {
589 let pre = br_precompute(1e-31, 1e-5, 1e6, 8e4, 1e6, 1.0, 0.0, 1.0);
591 assert!(pre.is_none(), "Should return None for theta_e < 1e-30");
592
593 let pre = br_precompute(1e-5, 1e-5, 1e6, 8e4, 1e-31, 1.0, 0.0, 1.0);
595 assert!(pre.is_none(), "Should return None for n_e < 1e-30");
596
597 let pre = br_precompute(1e-31, 1e-5, 1e6, 8e4, 1e-31, 1.0, 0.0, 1.0);
599 assert!(pre.is_none(), "Should return None when both are tiny");
600 }
601
602 #[test]
603 fn test_gaunt_ff_nr_guards() {
604 assert_eq!(gaunt_ff_nr(0.1, 1e-31, 1.0), 1.0);
606 assert_eq!(gaunt_ff_nr(0.1, 0.0, 1.0), 1.0);
607
608 assert_eq!(gaunt_ff_nr(1e-31, 1e-5, 1.0), 1.0);
610 assert_eq!(gaunt_ff_nr(0.0, 1e-5, 1.0), 1.0);
611
612 let g = gaunt_ff_nr(0.1, 1e-5, 1.0);
614 assert!(g >= 1.0, "Gaunt factor should be >= 1.0, got {g}");
615 }
616
617 #[test]
618 fn test_br_hii_capped_at_nh() {
619 let theta_e = 1e-5;
621 let theta_z = theta_e;
622 let n_h = 1e6;
623 let n_he = 0.08 * n_h;
624 let n_e = 1.16 * n_h; let x_e = 1.16; let cosmo = crate::cosmology::Cosmology::default();
627
628 let k = br_emission_coefficient(0.1, theta_e, theta_z, n_h, n_he, n_e, x_e, &cosmo);
629 assert!(k.is_finite() && k >= 0.0, "K_BR should be finite: {k}");
631
632 let k_ref = br_emission_coefficient(0.1, theta_e, theta_z, n_h, n_he, n_e, 1.0, &cosmo);
634 assert!(
638 (k / k_ref.max(1e-30) - 1.0).abs() < 0.2,
639 "BR with x_e=1.16 should be within 20% of x_e=1.0: {k:.4e} vs {k_ref:.4e}"
640 );
641 }
642
643 #[test]
644 fn test_br_hardcoded_constants() {
645 let sqrt_6pi = (6.0 * std::f64::consts::PI).sqrt();
648 assert!(
649 (sqrt_6pi - 4.341_607_527_349_605_5).abs() < 1e-14,
650 "sqrt(6π) mismatch: {sqrt_6pi}"
651 );
652
653 let s3_pi_exact = 3.0_f64.sqrt() / std::f64::consts::PI;
655 assert!(
656 (S3_PI - s3_pi_exact).abs() < 1e-15,
657 "S3_PI mismatch: hardcoded={S3_PI}, computed={s3_pi_exact}"
658 );
659
660 let ln_225_exact = 2.25_f64.ln();
662 assert!(
663 (LN_2_25 - ln_225_exact).abs() < 1e-15,
664 "LN_2_25 mismatch: hardcoded={LN_2_25}, computed={ln_225_exact}"
665 );
666
667 let ln_1125_exact = 1.125_f64.ln();
669 assert!(
670 (LN_1_125 - ln_1125_exact).abs() < 1e-15,
671 "LN_1_125 mismatch: hardcoded={LN_1_125}, computed={ln_1125_exact}"
672 );
673
674 let prefactor_exact =
676 ALPHA_FS * LAMBDA_ELECTRON.powi(3) / (2.0 * std::f64::consts::PI * sqrt_6pi);
677 assert!(
678 (BR_PREFACTOR - prefactor_exact).abs() / prefactor_exact < 1e-14,
679 "BR_PREFACTOR mismatch: hardcoded={BR_PREFACTOR}, computed={prefactor_exact}"
680 );
681 }
682
683 #[test]
697 fn test_br_emission_coefficient_magnitude() {
698 let cosmo = crate::cosmology::Cosmology::default();
699 let z = 1.0e5;
700 let tz = crate::constants::theta_z(z);
701 let n_h = cosmo.n_h(z);
702 let n_he = cosmo.n_he(z);
703 let x_e = 1.0 + 2.0 * cosmo.f_he(); let n_e = cosmo.n_e(z, x_e);
705
706 let k_br = br_emission_coefficient(0.1, tz, tz, n_h, n_he, n_e, x_e, &cosmo);
708
709 eprintln!("K_BR(x=0.1, z=1e5) = {k_br:.4e}");
710 eprintln!(" BR_PREFACTOR = {BR_PREFACTOR:.4e}");
711 eprintln!(" theta_z = {tz:.4e}");
712 eprintln!(" n_H = {n_h:.4e}, n_He = {n_he:.4e}, n_e = {n_e:.4e}");
713
714 assert!(k_br > 0.0, "K_BR must be positive, got {k_br}");
716
717 assert!(
720 k_br > 1e-12 && k_br < 1e-4,
721 "K_BR(x=0.1, z=1e5) = {k_br:.4e} is outside physically reasonable \
722 range [1e-12, 1e-4]. Historical /n_e bug gave ~10^11."
723 );
724
725 let hand_estimate = 6.6e-9;
727 let ratio = k_br / hand_estimate;
728 assert!(
729 ratio > 0.01 && ratio < 100.0,
730 "K_BR = {k_br:.4e} differs from hand estimate {hand_estimate:.4e} by {ratio:.1e}x"
731 );
732
733 let k_br_x1 = br_emission_coefficient(1.0, tz, tz, n_h, n_he, n_e, x_e, &cosmo);
735 eprintln!("K_BR(x=1.0, z=1e5) = {k_br_x1:.4e}");
736 assert!(
737 k_br_x1 > 1e-12 && k_br_x1 < 1e-4,
738 "K_BR(x=1.0, z=1e5) = {k_br_x1:.4e} outside [1e-12, 1e-4]"
739 );
740
741 assert!(
743 k_br > k_br_x1,
744 "K_BR should decrease with x: K(0.1)={k_br:.4e} < K(1.0)={k_br_x1:.4e}"
745 );
746 }
747}