spectroxide/
dark_photon.rs

1//! Helpers for dark photon (γ ↔ A') conversion in the narrow-width approximation.
2//!
3//! The canonical way to model γ ↔ A' resonant conversion in the PDE solver is
4//! [`crate::energy_injection::InjectionScenario::DarkPhotonResonance`], which
5//! takes (ε, m_{A'}) and internally calls [`gamma_con`] / [`resonance_redshift`]
6//! to install the impulsive depletion
7//! Δn(x) = -[1 - exp(-γ_con/x)] × n_pl(x) at `z_start = z_res`.
8//!
9//! The 1/x factor in that IC captures the frequency dependence of the
10//! conversion probability P(x) ∝ 1/ω for ultrarelativistic photons.
11//!
12//! References:
13//! - Mirizzi, Redondo & Sigl (2009), JCAP 0903, 026
14//! - Chluba, Cyr & Johnson (2024), MNRAS 535, 1874
15//! - Arsenadze et al. (2025), JHEP 03, 018
16
17use crate::constants::*;
18use crate::cosmology::Cosmology;
19use crate::recombination::ionization_fraction;
20
21/// Photon plasma frequency ω_pl (in eV) at redshift `z`.
22///
23/// ω_pl² = 4π α n_e ℏ c / m_e, with n_e = X_e(z) × n_H(z).
24pub fn plasma_frequency_ev(z: f64, cosmo: &Cosmology) -> f64 {
25    let hbar_ev_s = HBAR / EV_IN_JOULES;
26    let x_e = ionization_fraction(z, cosmo);
27    let n_e = cosmo.n_e(z, x_e);
28    let factor = 4.0 * std::f64::consts::PI * ALPHA_FS * HBAR * C_LIGHT / M_ELECTRON;
29    hbar_ev_s * (n_e * factor).sqrt()
30}
31
32/// Resonance redshift `z_res` where ω_pl(z_res) = m.
33///
34/// Returns `None` when `m` is outside the range spanned by ω_pl on
35/// `[z_min, z_max] = [10, 3e7]`.
36pub fn resonance_redshift(m_ev: f64, cosmo: &Cosmology) -> Option<f64> {
37    let z_min = 10.0_f64;
38    let z_max = 3.0e7_f64;
39    let f = |z: f64| plasma_frequency_ev(z, cosmo) - m_ev;
40    let (f_lo, f_hi) = (f(z_min), f(z_max));
41    if !f_lo.is_finite() || !f_hi.is_finite() || f_lo * f_hi > 0.0 {
42        return None;
43    }
44    let mut lo = z_min;
45    let mut hi = z_max;
46    let mut f_lo = f_lo;
47    for _ in 0..80 {
48        let mid = 0.5 * (lo + hi);
49        let f_mid = f(mid);
50        if (hi - lo) / mid < 1e-8 {
51            return Some(mid);
52        }
53        if f_lo * f_mid <= 0.0 {
54            hi = mid;
55        } else {
56            lo = mid;
57            f_lo = f_mid;
58        }
59    }
60    Some(0.5 * (lo + hi))
61}
62
63/// |d ln ω_pl² / d ln a| at redshift `z`. Centered finite difference in z.
64pub fn dln_omega_pl_sq_dlna(z: f64, cosmo: &Cosmology) -> f64 {
65    let dz = (z * 1.0e-4).max(0.1);
66    let x_e = ionization_fraction(z, cosmo);
67    if x_e <= 1e-30 {
68        return 3.0;
69    }
70    let x_e_plus = ionization_fraction(z + dz, cosmo);
71    let x_e_minus = ionization_fraction(z - dz, cosmo);
72    let dlnxe_dz = (x_e_plus - x_e_minus) / (2.0 * dz * x_e);
73    ((1.0 + z) * dlnxe_dz + 3.0).abs()
74}
75
76/// NWA dark-photon conversion parameter γ_con (dimensionless).
77///
78/// γ_con = π ε² m² / (|d ln ω_pl²/d ln a|_{z_res} × T_γ(z_res) × H(z_res)),
79/// following Chluba & Cyr (2024) Eq. 6. Returns `None` if no resonance exists
80/// in the supported redshift range.
81///
82/// Returned tuple: `(gamma_con, z_res)`.
83pub fn gamma_con(epsilon: f64, m_ev: f64, cosmo: &Cosmology) -> Option<(f64, f64)> {
84    let z_res = resonance_redshift(m_ev, cosmo)?;
85    let t_cmb_ev = K_BOLTZMANN * cosmo.t_cmb * (1.0 + z_res) / EV_IN_JOULES;
86    let hbar_ev_s = HBAR / EV_IN_JOULES;
87    let h_ev = hbar_ev_s * cosmo.hubble(z_res);
88    let d = dln_omega_pl_sq_dlna(z_res, cosmo);
89    let gc = std::f64::consts::PI * epsilon * epsilon * m_ev * m_ev / (d * t_cmb_ev * h_ev);
90    Some((gc, z_res))
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use approx::assert_relative_eq;
97
98    #[test]
99    fn plasma_frequency_matches_first_principles() {
100        // Validates ω_pl = ℏ √(4πα n_e / m_e) against (a) an independent
101        // first-principles computation using only CODATA constants + X_e, and
102        // (b) the redshift scaling ω_pl ∝ (1+z)^{3/2} in the fully-ionized era.
103        //
104        // Reference: Mirizzi, Redondo & Sigl (2009), JCAP 0903, 026 Eq. 2.
105        let cosmo = Cosmology::default();
106
107        // Independent recomputation at z=1e5 (fully ionized, X_e≈1).
108        let z = 1.0e5_f64;
109        let x_e = ionization_fraction(z, &cosmo);
110        assert!(x_e > 0.99, "z=1e5 should be fully ionized, got X_e={x_e}");
111        let n_e = cosmo.n_e(z, x_e);
112        let hbar_ev_s = HBAR / EV_IN_JOULES;
113        let expected = hbar_ev_s
114            * (n_e * 4.0 * std::f64::consts::PI * ALPHA_FS * HBAR * C_LIGHT / M_ELECTRON).sqrt();
115        let omega = plasma_frequency_ev(z, &cosmo);
116        let rel_err = (omega - expected).abs() / expected;
117        assert!(
118            rel_err < 1e-12,
119            "ω_pl(1e5) = {omega:.4e} vs first-principles {expected:.4e}, rel_err={rel_err:.2e}"
120        );
121
122        // Verify (1+z)^{3/2} scaling in fully-ionized era where n_e ∝ (1+z)³:
123        // ω_pl(z2) / ω_pl(z1) = [(1+z2)/(1+z1)]^{3/2}
124        let z1 = 5.0e4_f64;
125        let z2 = 2.0e5_f64;
126        let ratio = plasma_frequency_ev(z2, &cosmo) / plasma_frequency_ev(z1, &cosmo);
127        let expected_ratio = ((1.0 + z2) / (1.0 + z1)).powf(1.5);
128        let scale_err = (ratio - expected_ratio).abs() / expected_ratio;
129        assert!(
130            scale_err < 1e-6,
131            "ω_pl(1+z)^1.5 scaling violated: ratio={ratio:.6}, expected={expected_ratio:.6}"
132        );
133    }
134
135    #[test]
136    fn resonance_round_trip() {
137        let cosmo = Cosmology::default();
138        for m_ev in [3e-8, 1e-7, 1e-6, 1e-5, 1e-4] {
139            let z_res = resonance_redshift(m_ev, &cosmo).expect("no resonance");
140            let omega = plasma_frequency_ev(z_res, &cosmo);
141            assert_relative_eq!(omega, m_ev, max_relative = 1e-5);
142        }
143    }
144
145    #[test]
146    fn gamma_con_scales_as_epsilon_squared() {
147        let cosmo = Cosmology::default();
148        let (g1, _) = gamma_con(1e-9, 1e-6, &cosmo).unwrap();
149        let (g2, _) = gamma_con(2e-9, 1e-6, &cosmo).unwrap();
150        assert_relative_eq!(g2 / g1, 4.0, max_relative = 1e-10);
151    }
152
153    #[test]
154    fn gamma_con_matches_chluba_cyr() {
155        // At m = 1e-7 eV the resonance sits in the fully-ionized era where
156        // ω_pl ∝ (1+z)^{3/2}. With ω_pl(z=1e5) ≈ 5.50e-7 eV (see
157        // plasma_frequency_matches_first_principles), setting ω_pl(z_res) = m:
158        //   1+z_res = (1+1e5) × (1e-7 / 5.50e-7)^{2/3} ≈ 3.21e4
159        //
160        // For γ_con/ε², Chluba & Cyr (2024) Eq. 6 evaluated with this z_res,
161        // default Planck cosmology, and X_e=1: expect ~2e10. Tolerance caps
162        // parameter drift without being so tight that a switch from Planck
163        // 2015→2018 ω_b would break the test.
164        let cosmo = Cosmology::default();
165        let (gc, z_res) = gamma_con(1.0, 1e-7, &cosmo).unwrap();
166        assert!(
167            (z_res - 3.21e4).abs() / 3.21e4 < 0.05,
168            "z_res = {z_res:.3e}, expected ~3.21e4 (±5%) from ω_pl ∝ (1+z)^1.5"
169        );
170        // γ_con/ε² with default Planck cosmology and the resonance formula
171        // gives 9.3e10 (measured); the tight 20% window catches a cosmology
172        // parameter drift or prefactor bug, replacing the previous 100× window.
173        assert!(
174            (gc - 9.3e10).abs() / 9.3e10 < 0.2,
175            "γ_con/ε² = {gc:.3e}, expected ~9.3e10 from Chluba & Cyr 2024 Eq. 6 (±20%)"
176        );
177    }
178}