spectroxide/
energy_injection.rs

1//! Energy injection scenarios for the early Universe.
2//!
3//! Provides heating rate functions Q̇(z) for various physical mechanisms:
4//! - Single (quasi-instantaneous) energy injection
5//! - Decaying particles (heat or photon channel)
6//! - Annihilating dark matter (s-wave and p-wave)
7//! - Monochromatic photon injection
8//! - Tabulated heating / photon source (from external data)
9//! - Custom (user-supplied function)
10//!
11//! All heating rates are expressed as d(Δρ_γ/ρ_γ)/dt in units of 1/s.
12//!
13//! References:
14//! - Chluba & Sunyaev (2012), MNRAS 419, 1294 [Eq. 22-30]
15
16use crate::constants::*;
17use crate::cosmology::Cosmology;
18use crate::grid::RefinementZone;
19use crate::spectrum::planck;
20
21/// Compute vacuum survival fraction for decaying particle photon injection.
22///
23/// Returns exp(-Γ_X × t(z)), where t(z) is the cosmic time at redshift z.
24fn vacuum_survival(z: f64, gamma_x: f64, cosmo: &Cosmology) -> f64 {
25    (-gamma_x * cosmo.cosmic_time(z)).exp()
26}
27
28/// Energy injection scenario specification.
29#[non_exhaustive]
30pub enum InjectionScenario {
31    /// Single burst at redshift z_h with fractional energy Δρ/ρ.
32    SingleBurst {
33        /// Central injection redshift.
34        z_h: f64,
35        /// Fractional energy injected into the photon bath `Δρ/ρ`.
36        delta_rho_over_rho: f64,
37        /// Gaussian width in redshift of the injection profile.
38        sigma_z: f64,
39    },
40
41    /// Decaying particle with lifetime 1/Γ_X
42    DecayingParticle {
43        /// f*_X: energy per baryon released, in eV.
44        f_x: f64,
45        /// Γ_X: decay rate, in 1/s.
46        gamma_x: f64,
47    },
48
49    /// Annihilating dark matter (s-wave, <σv> = const)
50    AnnihilatingDM {
51        /// f_ann: energy injection rate parameter [eV/s].
52        /// Defined as f_eff × ⟨σv⟩ × m_χ × n_χ,0² / n_H,0 (paper convention).
53        /// Rate: dE/(dt dV) = f_ann × n_H(z) × (1+z)³.
54        /// This matches the CosmoTherm convention (e.g. f_ann = 1e-22 eV/s for s-wave).
55        f_ann: f64,
56    },
57
58    /// Annihilating dark matter (p-wave, <σv> ∝ v² ∝ T ∝ (1+z))
59    ///
60    /// Rate: dE/(dt dV) = f_ann × n_H(z) × (1+z)⁴, an extra (1+z) relative to
61    /// s-wave capturing the velocity-dependent cross section <σv> ∝ v² ∝ T ∝ (1+z).
62    AnnihilatingDMPWave {
63        /// f_ann: energy injection rate parameter [eV/s].
64        /// Same definition as AnnihilatingDM but includes the present-day value
65        /// of the velocity-dependent cross section.
66        f_ann: f64,
67    },
68
69    /// Monochromatic photon injection/removal at a specific frequency.
70    ///
71    /// Injects ΔN/N photons as a Gaussian in both frequency and redshift,
72    /// approximating a delta-function injection at (x_inj, z_h). This creates
73    /// both energy AND photon number perturbations, producing qualitatively
74    /// different spectral distortions from pure energy injection.
75    ///
76    /// Key physics: injection at x < x₀ ≈ 3.60 produces **negative** μ.
77    ///
78    /// The heating rate method returns the energy injection rate from the
79    /// photon injection: d(Δρ/ρ)/dt = (α_ρ × x_inj) × d(ΔN/N)/dt.
80    /// The frequency-dependent source is applied separately via
81    /// `photon_source_rate`.
82    ///
83    /// References:
84    ///   Chluba (2015), arXiv:1506.06582
85    ///   Arsenadze et al. (2025), arXiv:2409.12940, Appendix C+D
86    MonochromaticPhotonInjection {
87        /// Injection frequency (dimensionless x = hν/(kT_z))
88        x_inj: f64,
89        /// Total fractional photon number to inject: ΔN/N
90        delta_n_over_n: f64,
91        /// Central injection redshift
92        z_h: f64,
93        /// Gaussian width in redshift
94        sigma_z: f64,
95        /// Gaussian width in frequency (should match grid resolution)
96        sigma_x: f64,
97    },
98
99    /// Decaying particle X → γγ (spontaneous vacuum decay).
100    ///
101    /// Each decay produces two photons at x_inj(z) = x_inj_0 / (1+z),
102    /// where x_inj_0 = E_γ/(kT_0) = m_X c²/(2kT_0).
103    ///
104    /// The photon source term follows Bolliet & Chluba (2021), Eq. 3:
105    ///   dn/dt|_inj = G₂ × f_inj × Γ_X × S(z) × G(x, x_inj, σ_x) / x²
106    ///
107    /// where S(z) = exp(-Γ_X × t(z)) is the vacuum survival fraction.
108    ///
109    /// **NOTE**: Stimulated emission (Bose enhancement) is NOT included.
110    /// Because X → γγ deposits **both** final-state photons into the same
111    /// mode at x_inj, the physical rate carries a factor (1 + n(x_inj))²
112    /// (one (1+n) per emitted photon, squared because both land in the
113    /// same occupied mode). This is significant at x_inj ≪ 1 where
114    /// n_pl ≈ 1/x_inj ≫ 1, and is sub-percent for x_inj ≳ few. See
115    /// commit 0b6cfa4 for a prototype implementation.
116    ///
117    /// All injected photons are routed through `photon_source_rate()` and
118    /// are evolved self-consistently by the PDE solver (Compton + DC/BR).
119    ///
120    /// Reference: Bolliet & Chluba (2021), MNRAS 507, 3148 [arXiv:2012.07292]
121    DecayingParticlePhoton {
122        /// x_inj,0 = E_γ/(kT_0) = m_X c²/(2kT_0)
123        x_inj_0: f64,
124        /// Dimensionless injection amplitude (Eq. 5 of B&C 2021).
125        /// f_inj ≈ 2(Ω_cdm/Ω_γ) × f_dm × G₃/(G₂ × x_inj,0)
126        f_inj: f64,
127        /// Γ_X: vacuum decay rate [1/s]
128        gamma_x: f64,
129    },
130
131    /// Dark photon (γ ↔ A') resonant conversion in the narrow-width
132    /// approximation.
133    ///
134    /// Applied as an **initial condition** at the resonance redshift:
135    /// Δn(x) = -[1 - exp(-γ_con/x)] × n_pl(x) at z_start = z_res, where
136    /// γ_con = π ε² m² / (|d ln ω_pl²/d ln a|_{z_res} × T_γ(z_res) × H(z_res)).
137    /// The solver then evolves this IC with Kompaneets + DC/BR.
138    ///
139    /// The 1/x factor in the conversion probability captures the frequency
140    /// dependence P(x) ∝ 1/ω for ultrarelativistic photons.
141    ///
142    /// References:
143    ///   Mirizzi, Redondo & Sigl (2009), JCAP 0903, 026
144    ///   Chluba, Cyr & Johnson (2024), MNRAS 535, 1874
145    ///   Arsenadze et al. (2025), JHEP 03, 018
146    DarkPhotonResonance {
147        /// Kinetic mixing parameter ε
148        epsilon: f64,
149        /// Dark photon mass m_{A'}, in eV.
150        m_ev: f64,
151    },
152
153    /// Tabulated heating rate loaded from a file.
154    ///
155    /// The z_table and rate_table are sorted ascending in z.
156    /// `heating_rate_per_redshift(z)` interpolates linearly in log(z),
157    /// returning 0 outside the table bounds.
158    ///
159    /// This enables Python (or any external tool) to define arbitrary
160    /// heating rates by writing a CSV table and passing it to the Rust
161    /// PDE solver.
162    TabulatedHeating {
163        /// Redshift grid (ascending)
164        z_table: Vec<f64>,
165        /// d(Δρ/ρ)/dz at each z (positive = heating)
166        rate_table: Vec<f64>,
167    },
168
169    /// Tabulated frequency-dependent photon source loaded from a file.
170    ///
171    /// The source function is defined on a 2D grid (z, x), with bilinear
172    /// interpolation.  Returns 0 outside the table bounds.
173    TabulatedPhotonSource {
174        /// Redshift grid (ascending)
175        z_table: Vec<f64>,
176        /// Frequency grid (ascending)
177        x_grid: Vec<f64>,
178        /// Source rate: `source_2d[iz][ix] = d(Δn)/dz` at `(z_table[iz], x_grid[ix])`.
179        source_2d: Vec<Vec<f64>>,
180    },
181
182    /// Custom heating function
183    Custom(Box<dyn Fn(f64, &Cosmology) -> f64 + Send + Sync>),
184}
185
186/// Interpolate a value from a table sorted ascending in z, using linear
187/// interpolation in log(z). Returns 0 outside the table bounds.
188fn interp_log_z(z: f64, z_table: &[f64], val_table: &[f64]) -> f64 {
189    if z_table.is_empty() || z < z_table[0] || z > z_table[z_table.len() - 1] {
190        return 0.0;
191    }
192    let log_z = z.ln();
193    // Binary search on log(z_table)
194    let idx = match z_table.binary_search_by(|probe| probe.total_cmp(&z)) {
195        Ok(i) => return val_table[i],
196        Err(i) => {
197            if i == 0 {
198                return val_table[0];
199            }
200            if i >= z_table.len() {
201                return val_table[z_table.len() - 1];
202            }
203            i - 1
204        }
205    };
206    let log_z_lo = z_table[idx].ln();
207    let log_z_hi = z_table[idx + 1].ln();
208    let t = (log_z - log_z_lo) / (log_z_hi - log_z_lo);
209    val_table[idx] + t * (val_table[idx + 1] - val_table[idx])
210}
211
212/// Bilinear interpolation on a 2D table (z ascending, x ascending).
213/// Returns 0 outside bounds.
214fn interp_2d(z: f64, x: f64, z_table: &[f64], x_grid: &[f64], source_2d: &[Vec<f64>]) -> f64 {
215    if z_table.is_empty() || x_grid.is_empty() {
216        return 0.0;
217    }
218    if z < z_table[0] || z > z_table[z_table.len() - 1] {
219        return 0.0;
220    }
221    if x < x_grid[0] || x > x_grid[x_grid.len() - 1] {
222        return 0.0;
223    }
224
225    // Find z index
226    let iz = match z_table.binary_search_by(|p| p.total_cmp(&z)) {
227        Ok(i) => i.min(z_table.len() - 2),
228        Err(i) => {
229            if i == 0 {
230                0
231            } else {
232                (i - 1).min(z_table.len() - 2)
233            }
234        }
235    };
236    let tz = if iz + 1 < z_table.len() && z_table[iz + 1] > z_table[iz] {
237        (z - z_table[iz]) / (z_table[iz + 1] - z_table[iz])
238    } else {
239        0.0
240    };
241
242    // Find x index
243    let ix = match x_grid.binary_search_by(|p| p.total_cmp(&x)) {
244        Ok(i) => i.min(x_grid.len() - 2),
245        Err(i) => {
246            if i == 0 {
247                0
248            } else {
249                (i - 1).min(x_grid.len() - 2)
250            }
251        }
252    };
253    let tx = if ix + 1 < x_grid.len() && x_grid[ix + 1] > x_grid[ix] {
254        (x - x_grid[ix]) / (x_grid[ix + 1] - x_grid[ix])
255    } else {
256        0.0
257    };
258
259    let f00 = source_2d[iz][ix];
260    let f01 = source_2d[iz].get(ix + 1).copied().unwrap_or(f00);
261    let f10 = source_2d.get(iz + 1).map(|r| r[ix]).unwrap_or(f00);
262    let f11 = source_2d
263        .get(iz + 1)
264        .and_then(|r| r.get(ix + 1))
265        .copied()
266        .unwrap_or(f00);
267
268    (1.0 - tz) * ((1.0 - tx) * f00 + tx * f01) + tz * ((1.0 - tx) * f10 + tx * f11)
269}
270
271/// Load a tabulated heating rate from a CSV file.
272///
273/// File format: one header line (`z,dq_dz`), then data rows.
274/// The z column must be positive. Data is sorted ascending by z.
275///
276/// Returns `TabulatedHeating` variant.
277pub fn load_heating_table(path: &str) -> Result<InjectionScenario, String> {
278    let content = std::fs::read_to_string(path)
279        .map_err(|e| format!("Failed to read heating table '{path}': {e}"))?;
280
281    let mut z_table = Vec::new();
282    let mut rate_table = Vec::new();
283    let mut seen_data = false;
284
285    for (i, line) in content.lines().enumerate() {
286        let line = line.trim();
287        if line.is_empty() || line.starts_with('#') {
288            continue;
289        }
290        // Skip header line (first non-comment, non-empty line that looks like a header)
291        if !seen_data
292            && line.contains("z")
293            && !line.chars().next().map_or(true, |c| c.is_ascii_digit())
294        {
295            continue;
296        }
297        seen_data = true;
298        let parts: Vec<&str> = line.split(',').collect();
299        if parts.len() < 2 {
300            return Err(format!(
301                "Heating table line {}: expected 'z,dq_dz', got '{line}'",
302                i + 1
303            ));
304        }
305        let z: f64 = parts[0]
306            .trim()
307            .parse()
308            .map_err(|_| format!("Heating table line {}: invalid z '{}'", i + 1, parts[0]))?;
309        let rate: f64 = parts[1]
310            .trim()
311            .parse()
312            .map_err(|_| format!("Heating table line {}: invalid rate '{}'", i + 1, parts[1]))?;
313        z_table.push(z);
314        rate_table.push(rate);
315    }
316
317    // Sort by z ascending
318    let mut indices: Vec<usize> = (0..z_table.len()).collect();
319    indices.sort_by(|&a, &b| z_table[a].total_cmp(&z_table[b]));
320    let z_sorted: Vec<f64> = indices.iter().map(|&i| z_table[i]).collect();
321    let rate_sorted: Vec<f64> = indices.iter().map(|&i| rate_table[i]).collect();
322
323    Ok(InjectionScenario::TabulatedHeating {
324        z_table: z_sorted,
325        rate_table: rate_sorted,
326    })
327}
328
329/// Load a tabulated photon source from a CSV file.
330///
331/// File format:
332///   Header: `z,x1,x2,...,xN`
333///   Each row: `z_i,source(x1,z_i),source(x2,z_i),...,source(xN,z_i)`
334///
335/// Returns `TabulatedPhotonSource` variant.
336pub fn load_photon_source_table(path: &str) -> Result<InjectionScenario, String> {
337    let content = std::fs::read_to_string(path)
338        .map_err(|e| format!("Failed to read photon source table '{path}': {e}"))?;
339
340    let mut lines = content
341        .lines()
342        .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'));
343
344    // Parse header to get x grid
345    let header = lines
346        .next()
347        .ok_or_else(|| "Photon source table is empty".to_string())?;
348    let header_parts: Vec<&str> = header.split(',').collect();
349    if header_parts.len() < 2 {
350        return Err("Photon source table header must have at least z and one x value".to_string());
351    }
352    let mut x_grid = Vec::with_capacity(header_parts.len() - 1);
353    for s in &header_parts[1..] {
354        let val: f64 = s
355            .trim()
356            .parse()
357            .map_err(|_| format!("Invalid x value in header: '{s}'"))?;
358        x_grid.push(val);
359    }
360    let n_x = x_grid.len();
361
362    let mut z_table = Vec::new();
363    let mut source_2d = Vec::new();
364
365    for (i, line) in lines.enumerate() {
366        let parts: Vec<&str> = line.split(',').collect();
367        if parts.len() < 1 + n_x {
368            return Err(format!(
369                "Photon source table row {}: expected {} columns, got {}",
370                i + 2,
371                1 + n_x,
372                parts.len()
373            ));
374        }
375        let z: f64 = parts[0].trim().parse().map_err(|_| {
376            format!(
377                "Photon source table row {}: invalid z '{}'",
378                i + 2,
379                parts[0]
380            )
381        })?;
382        let mut row = Vec::with_capacity(n_x);
383        for s in &parts[1..1 + n_x] {
384            let val: f64 = s
385                .trim()
386                .parse()
387                .map_err(|_| format!("Photon source table row {}: invalid value '{s}'", i + 2))?;
388            row.push(val);
389        }
390        z_table.push(z);
391        source_2d.push(row);
392    }
393
394    // Sort by z ascending
395    let mut indices: Vec<usize> = (0..z_table.len()).collect();
396    indices.sort_by(|&a, &b| z_table[a].total_cmp(&z_table[b]));
397    let z_sorted: Vec<f64> = indices.iter().map(|&i| z_table[i]).collect();
398    let source_sorted: Vec<Vec<f64>> = indices.iter().map(|&i| source_2d[i].clone()).collect();
399
400    Ok(InjectionScenario::TabulatedPhotonSource {
401        z_table: z_sorted,
402        x_grid,
403        source_2d: source_sorted,
404    })
405}
406
407impl InjectionScenario {
408    /// CLI-friendly name for this injection scenario.
409    pub fn name(&self) -> &str {
410        match self {
411            InjectionScenario::SingleBurst { .. } => "single-burst",
412            InjectionScenario::DecayingParticle { .. } => "decaying-particle",
413            InjectionScenario::AnnihilatingDM { .. } => "annihilating-dm",
414            InjectionScenario::AnnihilatingDMPWave { .. } => "annihilating-dm-pwave",
415            InjectionScenario::MonochromaticPhotonInjection { .. } => "monochromatic-photon",
416            InjectionScenario::DecayingParticlePhoton { .. } => "decaying-particle-photon",
417            InjectionScenario::DarkPhotonResonance { .. } => "dark-photon-resonance",
418            InjectionScenario::TabulatedHeating { .. } => "tabulated-heating",
419            InjectionScenario::TabulatedPhotonSource { .. } => "tabulated-photon",
420
421            InjectionScenario::Custom(_) => "custom",
422        }
423    }
424
425    /// Validate parameters, returning an error message if invalid.
426    pub fn validate(&self) -> Result<(), String> {
427        match self {
428            InjectionScenario::SingleBurst {
429                z_h,
430                sigma_z,
431                delta_rho_over_rho,
432            } => {
433                if !z_h.is_finite() || *z_h <= 0.0 {
434                    return Err(format!("z_h must be positive and finite, got {z_h}"));
435                }
436                if !sigma_z.is_finite() || *sigma_z <= 0.0 {
437                    return Err(format!(
438                        "sigma_z must be positive and finite, got {sigma_z}"
439                    ));
440                }
441                if *sigma_z > 0.3 * *z_h {
442                    return Err(format!(
443                        "sigma_z must be ≲ 0.3 × z_h for the narrow-Gaussian approximation \
444                         (got sigma_z={sigma_z}, z_h={z_h}, ratio={:.3}). Wide injections \
445                         should use a continuous scenario instead.",
446                        sigma_z / z_h
447                    ));
448                }
449                if !delta_rho_over_rho.is_finite() {
450                    return Err(format!(
451                        "delta_rho_over_rho must be finite, got {delta_rho_over_rho}"
452                    ));
453                }
454                Ok(())
455            }
456            InjectionScenario::DecayingParticle { f_x, gamma_x } => {
457                if !f_x.is_finite() || *f_x <= 0.0 {
458                    return Err(format!("f_x must be positive and finite, got {f_x}"));
459                }
460                if !gamma_x.is_finite() || *gamma_x <= 0.0 {
461                    return Err(format!(
462                        "gamma_x must be positive and finite, got {gamma_x}"
463                    ));
464                }
465                Ok(())
466            }
467            InjectionScenario::AnnihilatingDM { f_ann }
468            | InjectionScenario::AnnihilatingDMPWave { f_ann } => {
469                if !f_ann.is_finite() || *f_ann <= 0.0 {
470                    return Err(format!("f_ann must be positive and finite, got {f_ann}"));
471                }
472                Ok(())
473            }
474            InjectionScenario::MonochromaticPhotonInjection {
475                x_inj,
476                delta_n_over_n,
477                z_h,
478                sigma_z,
479                sigma_x,
480            } => {
481                if !x_inj.is_finite() || *x_inj <= 0.0 {
482                    return Err(format!("x_inj must be positive and finite, got {x_inj}"));
483                }
484                if !delta_n_over_n.is_finite() {
485                    return Err(format!(
486                        "delta_n_over_n must be finite, got {delta_n_over_n}"
487                    ));
488                }
489                if !z_h.is_finite() || *z_h <= 0.0 {
490                    return Err(format!("z_h must be positive and finite, got {z_h}"));
491                }
492                if !sigma_z.is_finite() || *sigma_z <= 0.0 {
493                    return Err(format!(
494                        "sigma_z must be positive and finite, got {sigma_z}"
495                    ));
496                }
497                if *sigma_z > 0.3 * *z_h {
498                    return Err(format!(
499                        "sigma_z must be ≲ 0.3 × z_h for the narrow-Gaussian approximation \
500                         (got sigma_z={sigma_z}, z_h={z_h}, ratio={:.3})",
501                        sigma_z / z_h
502                    ));
503                }
504                if !sigma_x.is_finite() || *sigma_x <= 0.0 {
505                    return Err(format!(
506                        "sigma_x must be positive and finite, got {sigma_x}"
507                    ));
508                }
509                // The Gaussian-in-x source normalisation ∫ x² · profile(x) dx
510                // = G₂ uses the factor 1/x² evaluated at the local x, not x_inj.
511                // This is accurate when σ_x ≪ x_inj so that x ≈ x_inj over the
512                // Gaussian's support. Above σ_x/x_inj ~ 0.3 the normalisation
513                // drifts and the effective ΔN/N deviates from the requested
514                // value — reject rather than silently inject the wrong number
515                // of photons.
516                if *sigma_x > 0.3 * *x_inj {
517                    return Err(format!(
518                        "sigma_x must be ≲ 0.3 × x_inj for the Gaussian ΔN/N \
519                         normalisation to be accurate (got sigma_x={sigma_x}, \
520                         x_inj={x_inj}, ratio={:.3})",
521                        sigma_x / x_inj
522                    ));
523                }
524                Ok(())
525            }
526            InjectionScenario::DecayingParticlePhoton {
527                x_inj_0,
528                f_inj,
529                gamma_x,
530                ..
531            } => {
532                if !x_inj_0.is_finite() || *x_inj_0 <= 0.0 {
533                    return Err(format!(
534                        "x_inj_0 must be positive and finite, got {x_inj_0}"
535                    ));
536                }
537                if !f_inj.is_finite() || *f_inj <= 0.0 {
538                    return Err(format!("f_inj must be positive and finite, got {f_inj}"));
539                }
540                if !gamma_x.is_finite() || *gamma_x <= 0.0 {
541                    return Err(format!(
542                        "gamma_x must be positive and finite, got {gamma_x}"
543                    ));
544                }
545                Ok(())
546            }
547            InjectionScenario::DarkPhotonResonance { epsilon, m_ev } => {
548                if !epsilon.is_finite() || *epsilon <= 0.0 {
549                    return Err(format!(
550                        "epsilon must be positive and finite, got {epsilon}"
551                    ));
552                }
553                if !m_ev.is_finite() || *m_ev <= 0.0 {
554                    return Err(format!("m_ev must be positive and finite, got {m_ev}"));
555                }
556                Ok(())
557            }
558            InjectionScenario::TabulatedHeating {
559                z_table,
560                rate_table,
561            } => {
562                if z_table.is_empty() {
563                    return Err("z_table is empty".into());
564                }
565                if z_table.len() != rate_table.len() {
566                    return Err(format!(
567                        "z_table and rate_table must have same length, got {} and {}",
568                        z_table.len(),
569                        rate_table.len()
570                    ));
571                }
572                // Finiteness first so the monotonicity check below can rely on
573                // a total order (NaN slips past `<=` silently).
574                for (i, &z) in z_table.iter().enumerate() {
575                    if !z.is_finite() {
576                        return Err(format!("z_table[{i}]={z} is not finite"));
577                    }
578                }
579                for (i, &r) in rate_table.iter().enumerate() {
580                    if !r.is_finite() {
581                        return Err(format!("rate_table[{i}]={r} is not finite"));
582                    }
583                }
584                if z_table[0] <= 0.0 {
585                    return Err(format!(
586                        "z_table[0]={} must be positive (interpolation uses log z)",
587                        z_table[0]
588                    ));
589                }
590                // interp_log_z uses binary search in log(z) assuming strict
591                // ascending order. A non-monotone table silently produces
592                // garbage rates with no error — CLAUDE.md calls this out as
593                // a silent-failure mode. Reject at validate() time.
594                for i in 1..z_table.len() {
595                    if z_table[i] <= z_table[i - 1] {
596                        return Err(format!(
597                            "z_table must be strictly ascending: z[{}]={} not > z[{}]={}",
598                            i,
599                            z_table[i],
600                            i - 1,
601                            z_table[i - 1]
602                        ));
603                    }
604                }
605                Ok(())
606            }
607            InjectionScenario::TabulatedPhotonSource {
608                z_table,
609                x_grid,
610                source_2d,
611            } => {
612                if z_table.is_empty() {
613                    return Err("z_table is empty".into());
614                }
615                if x_grid.is_empty() {
616                    return Err("x_grid is empty".into());
617                }
618                if z_table.len() != source_2d.len() {
619                    return Err(format!(
620                        "z_table and source_2d must have same length, got {} and {}",
621                        z_table.len(),
622                        source_2d.len()
623                    ));
624                }
625                for (i, &z) in z_table.iter().enumerate() {
626                    if !z.is_finite() {
627                        return Err(format!("z_table[{i}]={z} is not finite"));
628                    }
629                }
630                for (i, &x) in x_grid.iter().enumerate() {
631                    if !x.is_finite() {
632                        return Err(format!("x_grid[{i}]={x} is not finite"));
633                    }
634                }
635                if z_table[0] <= 0.0 {
636                    return Err(format!("z_table[0]={} must be positive", z_table[0]));
637                }
638                if x_grid[0] <= 0.0 {
639                    return Err(format!("x_grid[0]={} must be positive", x_grid[0]));
640                }
641                for i in 1..z_table.len() {
642                    if z_table[i] <= z_table[i - 1] {
643                        return Err(format!(
644                            "z_table must be strictly ascending: z[{}]={} not > z[{}]={}",
645                            i,
646                            z_table[i],
647                            i - 1,
648                            z_table[i - 1]
649                        ));
650                    }
651                }
652                for i in 1..x_grid.len() {
653                    if x_grid[i] <= x_grid[i - 1] {
654                        return Err(format!(
655                            "x_grid must be strictly ascending: x[{}]={} not > x[{}]={}",
656                            i,
657                            x_grid[i],
658                            i - 1,
659                            x_grid[i - 1]
660                        ));
661                    }
662                }
663                for (iz, row) in source_2d.iter().enumerate() {
664                    if row.len() != x_grid.len() {
665                        return Err(format!(
666                            "source_2d[{iz}] has length {}, expected {} (len(x_grid))",
667                            row.len(),
668                            x_grid.len()
669                        ));
670                    }
671                    for (ix, &v) in row.iter().enumerate() {
672                        if !v.is_finite() {
673                            return Err(format!("source_2d[{iz}][{ix}]={v} is not finite"));
674                        }
675                    }
676                }
677                Ok(())
678            }
679            InjectionScenario::Custom(f) => {
680                // Spot-check the closure for finiteness across the full
681                // redshift band the solver typically integrates over. NaN
682                // or Inf values from a buggy user closure would otherwise
683                // propagate into the solver and trip a deep-stack panic
684                // far from the actual culprit.
685                //
686                // 16 log-spaced samples cover ~6 decades at < ½ dec/sample —
687                // enough to catch isolated singularities (e.g. 1/(z - z*))
688                // that 5-point spot-checks miss.
689                let cosmo = Cosmology::default();
690                let z_lo = 1.0e2_f64.ln();
691                let z_hi = 5.0e6_f64.ln();
692                for i in 0..16 {
693                    let t = i as f64 / 15.0;
694                    let z = (z_lo + t * (z_hi - z_lo)).exp();
695                    let val = f(z, &cosmo);
696                    if !val.is_finite() {
697                        return Err(format!(
698                            "Custom heating closure returned non-finite value {val} at z={z:.3e}; \
699                             reject closures that emit NaN/Inf in the integration band."
700                        ));
701                    }
702                }
703                Ok(())
704            }
705        }
706    }
707
708    /// Compute the heating rate d(Δρ_γ/ρ_γ)/dt at redshift z.
709    ///
710    /// Returns the rate in units of [1/s].
711    pub fn heating_rate(&self, z: f64, cosmo: &Cosmology) -> f64 {
712        match self {
713            InjectionScenario::SingleBurst {
714                z_h,
715                delta_rho_over_rho,
716                sigma_z,
717            } => {
718                // Narrow Gaussian in redshift
719                // d(Δρ/ρ)/dz = (Δρ/ρ) × Gaussian(z - z_h, σ_z)
720                // d(Δρ/ρ)/dt = d(Δρ/ρ)/dz × dz/dt = d(Δρ/ρ)/dz × (-H(1+z))
721                let gauss = (-(z - z_h).powi(2) / (2.0 * sigma_z * sigma_z)).exp()
722                    / (2.0 * std::f64::consts::PI * sigma_z * sigma_z).sqrt();
723                delta_rho_over_rho * gauss * cosmo.hubble(z) * (1.0 + z)
724            }
725
726            InjectionScenario::DecayingParticle { f_x, gamma_x } => {
727                // dE/dt = f*_X × Γ_X × N_H × exp(-Γ_X t)
728                // d(Δρ/ρ)/dt = dE/dt / ρ_γ
729                let t = cosmo.cosmic_time(z);
730                let n_h = cosmo.n_h(z);
731                let rho_gamma = cosmo.rho_gamma(z);
732
733                // f_x in eV → convert to Joules
734                let f_x_joules = f_x * EV_IN_JOULES;
735                f_x_joules * gamma_x * n_h * (-gamma_x * t).exp() / rho_gamma
736            }
737
738            InjectionScenario::AnnihilatingDM { f_ann } => {
739                // s-wave DM annihilation. Paper convention: f_ann [eV/s].
740                // dE/(dt dV) = f_ann × n_H(z) × (1+z)³
741                // d(Δρ/ρ)/dt = f_ann × n_H(z) × (1+z)³ / ρ_γ(z)
742                //            ∝ (1+z)⁶ / (1+z)⁴ = (1+z)²
743                let n_h = cosmo.n_h(z);
744                let rho_gamma = cosmo.rho_gamma(z);
745                let f_ann_si = f_ann * EV_IN_JOULES;
746                f_ann_si * n_h * (1.0 + z).powi(3) / rho_gamma
747            }
748
749            InjectionScenario::AnnihilatingDMPWave { f_ann } => {
750                // p-wave DM annihilation: ⟨σv⟩ ∝ v² ∝ T ∝ (1+z), adding one (1+z).
751                // dE/(dt dV) = f_ann × n_H(z) × (1+z)⁴
752                // d(Δρ/ρ)/dt = f_ann × n_H(z) × (1+z)⁴ / ρ_γ(z)
753                //            ∝ (1+z)⁷ / (1+z)⁴ = (1+z)³
754                let n_h = cosmo.n_h(z);
755                let rho_gamma = cosmo.rho_gamma(z);
756                let f_ann_si = f_ann * EV_IN_JOULES;
757                f_ann_si * n_h * (1.0 + z).powi(4) / rho_gamma
758            }
759
760            InjectionScenario::MonochromaticPhotonInjection { .. } => {
761                // Photon injection has no direct electron heating.
762                // All photons are injected via photon_source_rate() into Δn,
763                // which adds energy directly.
764                0.0
765            }
766
767            InjectionScenario::DecayingParticlePhoton { .. } => {
768                // All injected energy goes through photon_source_rate() and is
769                // evolved self-consistently by the PDE solver (Compton + DC/BR).
770                0.0
771            }
772
773            InjectionScenario::DarkPhotonResonance { .. } => {
774                // Dark photon resonance is applied as an initial condition at z_res
775                // via `initial_delta_n`, not as a bulk heating rate.
776                0.0
777            }
778
779            InjectionScenario::TabulatedHeating {
780                z_table,
781                rate_table,
782            } => {
783                // rate_table stores d(Δρ/ρ)/dz (positive = heating).
784                // Convert to d(Δρ/ρ)/dt = d(Δρ/ρ)/dz × |dz/dt| = d(Δρ/ρ)/dz × H(z)(1+z)
785                let dq_dz = interp_log_z(z, z_table, rate_table);
786                dq_dz * cosmo.hubble(z) * (1.0 + z)
787            }
788
789            InjectionScenario::TabulatedPhotonSource { .. } => {
790                // All energy goes through the photon channel
791                0.0
792            }
793
794            InjectionScenario::Custom(f) => f(z, cosmo),
795        }
796    }
797
798    /// Frequency-dependent photon injection/removal rate.
799    ///
800    /// Returns d(Δn)/dt at frequency x, in units of [1/s].
801    /// This is the direct modification to the photon occupation number at
802    /// each frequency, separate from the bulk heating captured by `heating_rate`.
803    ///
804    /// For `MonochromaticPhotonInjection`, returns a Gaussian profile in
805    /// both x and z centered at (x_inj, z_h).
806    ///
807    /// Returns 0.0 for scenarios without frequency-dependent photon injection.
808    pub fn photon_source_rate(&self, x: f64, z: f64, cosmo: &Cosmology) -> f64 {
809        match self {
810            InjectionScenario::MonochromaticPhotonInjection {
811                x_inj,
812                delta_n_over_n,
813                z_h,
814                sigma_z,
815                sigma_x,
816            } => {
817                // Inject ALL photons — no P_s filtering.
818                // DC/BR absorption is handled dynamically by the PDE solver.
819                // Energy from absorbed photons is recovered via the solver's
820                // source-only Kompaneets correction.
821
822                // Temporal profile: Gaussian in z
823                let gauss_z = (-(z - z_h).powi(2) / (2.0 * sigma_z * sigma_z)).exp()
824                    / (2.0 * std::f64::consts::PI * sigma_z * sigma_z).sqrt();
825
826                // Frequency profile: Gaussian in x, normalized so that
827                // ∫ x² × profile(x) dx = G₂ (preserving ΔN/N normalization)
828                let gauss_x = (-(x - x_inj).powi(2) / (2.0 * sigma_x * sigma_x)).exp()
829                    / (sigma_x * (2.0 * std::f64::consts::PI).sqrt());
830
831                // d(Δn)/dt = delta_n_over_n × G₂ / x² × gauss_x × gauss_z × H(1+z)
832                let rate_per_z =
833                    delta_n_over_n * G2_PLANCK / (x * x).max(1e-30) * gauss_x * gauss_z;
834                rate_per_z * cosmo.hubble(z) * (1.0 + z)
835            }
836            InjectionScenario::DecayingParticlePhoton {
837                x_inj_0,
838                f_inj,
839                gamma_x,
840            } => {
841                let x_inj = x_inj_0 / (1.0 + z);
842                let sigma_x = 0.05 * x_inj;
843
844                // Quick exit for frequencies far from injection
845                if sigma_x < 1e-30 || (x - x_inj).abs() > 5.0 * sigma_x {
846                    return 0.0;
847                }
848
849                // Gaussian frequency profile
850                let gauss_x = (-(x - x_inj).powi(2) / (2.0 * sigma_x * sigma_x)).exp()
851                    / (sigma_x * (2.0 * std::f64::consts::PI).sqrt());
852
853                let survival = vacuum_survival(z, *gamma_x, cosmo);
854
855                // dn/dt = G₂ × f_inj × Γ_X × S(z) × G(x, x_inj, σ_x) / x²
856                //
857                // This is the **spontaneous** emission rate. Because X → γγ
858                // puts **both** final-state photons into the same mode at
859                // x_inj, the full photon source carries a Bose-enhancement
860                // factor (1 + n_pl(x_inj))² (one (1+n) per emitted photon,
861                // squared for two photons in the same state). It is omitted
862                // here, matching Chluba (2015), and is validated for
863                // x_inj ≳ few where n_pl(x_inj) ≪ 1 (sub-percent correction).
864                //
865                // WARNING: for x_inj ≪ 1 the Bose factor grows as 1/x_inj²
866                // and dominates — e.g. at x_inj = 0.05, (1 + n_pl)² ≈ 440.
867                // Users injecting at very low frequencies must regard the
868                // resulting μ/y amplitudes as **lower bounds**. The
869                // redistribution dynamics (DC/BR absorption) still govern
870                // the observable shape, but the amplitude is systematically
871                // underestimated.
872                G2_PLANCK * f_inj * gamma_x * survival * gauss_x / (x * x).max(1e-30)
873            }
874
875            InjectionScenario::TabulatedPhotonSource {
876                z_table,
877                x_grid,
878                source_2d,
879            } => {
880                // Table stores d(Δn)/dz; convert to d(Δn)/dt [1/s] like TabulatedHeating.
881                let dn_dz = interp_2d(z, x, z_table, x_grid, source_2d);
882                dn_dz * cosmo.hubble(z) * (1.0 + z)
883            }
884
885            _ => 0.0,
886        }
887    }
888
889    /// Whether this scenario has frequency-dependent photon injection/depletion.
890    pub fn has_photon_source(&self) -> bool {
891        matches!(
892            self,
893            InjectionScenario::MonochromaticPhotonInjection { .. }
894                | InjectionScenario::DecayingParticlePhoton { .. }
895                | InjectionScenario::TabulatedPhotonSource { .. }
896        )
897    }
898
899    /// Return refinement zones for adaptive grid resolution near injection features.
900    ///
901    /// Photon injection scenarios need extra grid points near the injection
902    /// frequency to resolve the narrow Gaussian source profile and the
903    /// DC/BR absorption/re-emission dynamics at low x.
904    pub fn refinement_zones(&self) -> Vec<RefinementZone> {
905        match self {
906            InjectionScenario::MonochromaticPhotonInjection { x_inj, sigma_x, .. } => {
907                // Refine around x_inj ± 5σ_x with 300 log-spaced points
908                let half_width = 5.0 * sigma_x;
909                vec![RefinementZone {
910                    x_center: *x_inj,
911                    x_width: half_width,
912                    n_points: 300,
913                }]
914            }
915            InjectionScenario::DecayingParticlePhoton { x_inj_0, .. } => {
916                // The injection frequency redshifts: x_inj(z) = x_inj_0 / (1+z).
917                // At high z (e.g., z=1e6), x_inj can be ~1e-7 × x_inj_0.
918                // RefinementZone uses a linear (center, half-width) API, so
919                // we explicitly cover [x_lo, x_hi] with x_center the
920                // arithmetic mean and x_width the half-range. Downstream
921                // grid construction clamps to [x_min, x_max].
922                let x_lo = (x_inj_0 * 1e-7).max(1e-8);
923                let x_hi = *x_inj_0;
924                let x_center = 0.5 * (x_lo + x_hi);
925                let x_width = 0.5 * (x_hi - x_lo);
926                vec![RefinementZone {
927                    x_center,
928                    x_width,
929                    n_points: 400,
930                }]
931            }
932            _ => Vec::new(),
933        }
934    }
935
936    /// Return the characteristic injection redshift(s) for this scenario.
937    ///
938    /// For burst-like scenarios, returns `Some((z_center, z_upper))` where
939    /// `z_upper` is the highest redshift at which injection is active
940    /// (typically z_h + 5σ_z for Gaussians). `z_center` is the peak.
941    ///
942    /// For continuous scenarios (decaying particles, annihilation), returns
943    /// `None` — injection happens at all z.
944    pub fn characteristic_redshift(&self) -> Option<(f64, f64)> {
945        match self {
946            InjectionScenario::SingleBurst { z_h, sigma_z, .. } => {
947                Some((*z_h, z_h + 7.0 * sigma_z))
948            }
949            InjectionScenario::MonochromaticPhotonInjection { z_h, sigma_z, .. } => {
950                Some((*z_h, z_h + 7.0 * sigma_z))
951            }
952            // Continuous injection: active at all redshifts
953            _ => None,
954        }
955    }
956
957    /// Dark-photon NWA parameters (γ_con, z_res), if applicable.
958    ///
959    /// Returns `Some((γ_con, z_res))` for `DarkPhotonResonance`,
960    /// `None` for all other scenarios. Returns `None` if the resonance
961    /// falls outside the supported redshift range.
962    pub fn dark_photon_params(&self, cosmo: &Cosmology) -> Option<(f64, f64)> {
963        match self {
964            InjectionScenario::DarkPhotonResonance { epsilon, m_ev } => {
965                crate::dark_photon::gamma_con(*epsilon, *m_ev, cosmo)
966            }
967            _ => None,
968        }
969    }
970
971    /// Initial-condition perturbation Δn(x) to be installed at `z_start`.
972    ///
973    /// Scenarios that deposit their distortion as an impulsive event (notably
974    /// `DarkPhotonResonance`) return `Some(Δn_init)` here; the solver applies
975    /// it at `z_start` before beginning redshift evolution. All other
976    /// scenarios return `None` and are evolved from Δn = 0.
977    pub fn initial_delta_n(&self, x_grid: &[f64], cosmo: &Cosmology) -> Option<Vec<f64>> {
978        match self {
979            InjectionScenario::DarkPhotonResonance { .. } => {
980                let (gamma_con, _z_res) = self.dark_photon_params(cosmo)?;
981                let dn: Vec<f64> = x_grid
982                    .iter()
983                    .map(|&x| {
984                        let p = 1.0 - (-gamma_con / x).exp();
985                        -p * planck(x)
986                    })
987                    .collect();
988                Some(dn)
989            }
990            _ => None,
991        }
992    }
993
994    /// Suggest a lower `x_min` for the frequency grid when needed.
995    ///
996    /// Low-frequency photon injection can be artificially absorbed by the
997    /// Dirichlet boundary at `x_min` if the source support extends below the
998    /// existing grid floor.
999    pub fn suggested_x_min(&self) -> Option<f64> {
1000        match self {
1001            InjectionScenario::MonochromaticPhotonInjection { x_inj, .. } => {
1002                // Need x_min well below x_inj to prevent boundary absorption.
1003                // Use x_inj / 100, but never below 1e-7 (numerical floor).
1004                let suggested = (x_inj / 100.0).max(1e-7);
1005                if suggested < 1e-4 {
1006                    Some(suggested)
1007                } else {
1008                    None // default x_min = 1e-4 is fine
1009                }
1010            }
1011            InjectionScenario::DecayingParticlePhoton { x_inj_0, .. } => {
1012                let suggested = (x_inj_0 / 100.0).max(1e-7);
1013                if suggested < 1e-4 {
1014                    Some(suggested)
1015                } else {
1016                    None
1017                }
1018            }
1019            _ => None,
1020        }
1021    }
1022
1023    /// Check for strong distortion regime and return warnings.
1024    ///
1025    /// The Kompaneets equation solver uses a linearized perturbation approach
1026    /// (Δn = n - n_pl) that breaks down for |Δρ/ρ| > ~0.01. Returns a
1027    /// list of warning messages for scenarios that may exceed this limit.
1028    pub fn warn_strong_distortion(&self) -> Vec<String> {
1029        let mut warnings = Vec::new();
1030        match self {
1031            InjectionScenario::SingleBurst {
1032                delta_rho_over_rho, ..
1033            } if delta_rho_over_rho.abs() > 0.01 => {
1034                warnings.push(format!(
1035                    "Strong distortion regime: |Δρ/ρ| = {:.2e} > 0.01. \
1036                     The solver uses linearized Kompaneets and results will be \
1037                     inaccurate for large energy injections. \
1038                     Physical distortions are small (Δρ/ρ ~ 10⁻⁵ for μ-era).",
1039                    delta_rho_over_rho.abs()
1040                ));
1041            }
1042            InjectionScenario::MonochromaticPhotonInjection {
1043                delta_n_over_n,
1044                x_inj,
1045                ..
1046            } => {
1047                if delta_n_over_n.abs() > 0.01 {
1048                    warnings.push(format!(
1049                        "Strong distortion regime: |ΔN/N| = {:.2e} > 0.01. \
1050                         The solver uses linearized Kompaneets and results will be \
1051                         inaccurate for large photon injections.",
1052                        delta_n_over_n.abs()
1053                    ));
1054                }
1055                if *x_inj > 150.0 {
1056                    warnings.push(format!(
1057                        "High-frequency photon injection: x_inj = {:.1} exceeds the \
1058                         validated range (x_inj ≤ 150). PDE solver is stable but results \
1059                         are not validated against CosmoTherm or literature at this frequency.",
1060                        x_inj
1061                    ));
1062                }
1063            }
1064            _ => {}
1065        }
1066        warnings
1067    }
1068
1069    /// Warn when the dark-photon NWA resonance falls outside the validated
1070    /// redshift range (roughly z ∈ [50, 3×10⁶] per CLAUDE.md).
1071    ///
1072    /// Returns `Some(Err(msg))` when no resonance exists at all in the
1073    /// supported band (hard error), `Some(Ok(warnings))` with a regime warning
1074    /// when z_res lands outside the validated window, and `None` for
1075    /// non-dark-photon scenarios or when everything is fine.
1076    pub fn warn_dark_photon_range(&self, cosmo: &Cosmology) -> Vec<String> {
1077        let mut warnings = Vec::new();
1078        if let InjectionScenario::DarkPhotonResonance { m_ev, .. } = self {
1079            match self.dark_photon_params(cosmo) {
1080                None => {
1081                    warnings.push(format!(
1082                        "DarkPhotonResonance: no plasma-frequency resonance for m_ev={m_ev:.3e} \
1083                         in the searched band z ∈ [10, 3×10⁷]. The depletion IC will be zero \
1084                         and the run produces no distortion from this channel."
1085                    ));
1086                }
1087                Some((_g, z_res)) => {
1088                    if z_res < 50.0 {
1089                        warnings.push(format!(
1090                            "DarkPhotonResonance: z_res={z_res:.3e} is below the validated \
1091                             range (z ≳ 50). Recombination history and Compton coupling \
1092                             are not trusted at such low z; treat results as indicative."
1093                        ));
1094                    } else if z_res > 3.0e6 {
1095                        warnings.push(format!(
1096                            "DarkPhotonResonance: z_res={z_res:.3e} is above the validated \
1097                             range (z ≲ 3×10⁶). Kompaneets Fokker-Planck has O(θ_e²) \
1098                             corrections that grow above this; results may have percent-level \
1099                             systematic errors."
1100                        ));
1101                    }
1102                }
1103            }
1104        }
1105        warnings
1106    }
1107
1108    /// Warn when a tabulated-source table doesn't cover the solver's
1109    /// integration range `[z_end, z_start]`.
1110    ///
1111    /// `interp_log_z` / `interp_2d` return 0.0 outside the table — a silent
1112    /// extrapolation that produces zero injection at redshifts the user may
1113    /// have intended to cover. Surface the mismatch as a warning at build
1114    /// time so it can't go unnoticed.
1115    pub fn warn_tabulated_coverage(&self, z_start: f64, z_end: f64) -> Vec<String> {
1116        let mut warnings = Vec::new();
1117        let (z_table, name) = match self {
1118            InjectionScenario::TabulatedHeating { z_table, .. } => (z_table, "TabulatedHeating"),
1119            InjectionScenario::TabulatedPhotonSource { z_table, .. } => {
1120                (z_table, "TabulatedPhotonSource")
1121            }
1122            _ => return warnings,
1123        };
1124        if z_table.is_empty() {
1125            return warnings;
1126        }
1127        let z_min = z_table[0];
1128        let z_max = z_table[z_table.len() - 1];
1129        // 10% headroom on each side to avoid warning for benign edge cases
1130        if z_end < z_min * 0.9 {
1131            warnings.push(format!(
1132                "{name}: table covers z ∈ [{z_min:.3e}, {z_max:.3e}] but solver runs down \
1133                 to z_end={z_end:.3e}. Rates are silently zero below z_table.min(); \
1134                 the run will miss any physical injection below that bound."
1135            ));
1136        }
1137        if z_start > z_max * 1.1 {
1138            warnings.push(format!(
1139                "{name}: table covers z ∈ [{z_min:.3e}, {z_max:.3e}] but solver starts at \
1140                 z_start={z_start:.3e}. Rates are silently zero above z_table.max(); \
1141                 early evolution sees no injection."
1142            ));
1143        }
1144        warnings
1145    }
1146
1147    /// Warn if stimulated emission (Bose enhancement) is missing for photon decay.
1148    ///
1149    /// `DecayingParticlePhoton` uses the vacuum decay rate only. For the
1150    /// canonical X → γγ channel, the physical rate carries a factor
1151    /// (1 + n(x_inj))² (one (1+n) per emitted photon, both into the same
1152    /// mode at x_inj); for single-photon channels (e.g. X → γ X') the
1153    /// factor is (1 + n(x_inj)). The squared form is significant at
1154    /// x_inj ≪ 1 where n_pl ≈ 1/x_inj ≫ 1.
1155    pub fn warn_stimulated_emission(&self) -> Vec<String> {
1156        let mut warnings = Vec::new();
1157        if let InjectionScenario::DecayingParticlePhoton { x_inj_0, .. } = self {
1158            let n_pl = 1.0 / ((*x_inj_0).exp() - 1.0).max(1e-30);
1159            let factor_sq = (1.0 + n_pl).powi(2);
1160            warnings.push(format!(
1161                "DecayingParticlePhoton: stimulated emission (Bose enhancement) is NOT \
1162                 included. For X → γγ (both photons in the same mode at x_inj) the \
1163                 physical rate should include a factor (1 + n(x_inj))² which is \
1164                 significant at low x_inj. At x_inj_0 = {x_inj_0:.2e}, (1 + n)² at z=0 \
1165                 is ~{factor_sq:.0}. See Chluba (2015) for details."
1166            ));
1167        }
1168        warnings
1169    }
1170
1171    /// Compute the physical d(Δρ/ρ)/dz.
1172    ///
1173    /// **Sign convention**: For positive energy injection (heating_rate > 0),
1174    /// this returns a NEGATIVE value because energy enters as z decreases
1175    /// (dt > 0, dz < 0). This is the correct physical sign.
1176    ///
1177    /// **WARNING**: The Green's function routines (`mu_from_heating`, etc.)
1178    /// expect a POSITIVE dq/dz for heating. Use `heating_rate_per_redshift().abs()`
1179    /// or pass a positive Gaussian directly when calling Green's function methods.
1180    pub fn heating_rate_per_redshift(&self, z: f64, cosmo: &Cosmology) -> f64 {
1181        // d(Δρ/ρ)/dz = d(Δρ/ρ)/dt × dt/dz = -d(Δρ/ρ)/dt / (H(1+z))
1182        let rate = self.heating_rate(z, cosmo);
1183        -rate / (cosmo.hubble(z) * (1.0 + z))
1184    }
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189    use super::*;
1190
1191    #[test]
1192    fn test_single_burst_normalization() {
1193        let cosmo = Cosmology::default();
1194        let z_h = 2.0e5;
1195        let drho = 1e-5;
1196        let sigma = 100.0;
1197
1198        let scenario = InjectionScenario::SingleBurst {
1199            z_h,
1200            delta_rho_over_rho: drho,
1201            sigma_z: sigma,
1202        };
1203
1204        // Integrate d(Δρ/ρ)/dz over z to check normalization
1205        let n_z = 10000;
1206        let z_min = z_h - 5.0 * sigma;
1207        let z_max = z_h + 5.0 * sigma;
1208        let dz = (z_max - z_min) / n_z as f64;
1209
1210        let mut total = 0.0;
1211        for i in 0..n_z {
1212            let z = z_min + (i as f64 + 0.5) * dz;
1213            total += scenario.heating_rate_per_redshift(z, &cosmo).abs() * dz;
1214        }
1215
1216        assert!(
1217            (total - drho).abs() / drho < 0.05,
1218            "Integrated energy = {total:.3e}, expected {drho:.3e}"
1219        );
1220    }
1221
1222    #[test]
1223    fn test_interp_log_z_basic() {
1224        let z_table = vec![1e3, 1e4, 1e5, 1e6];
1225        let val_table = vec![1.0, 2.0, 3.0, 4.0];
1226
1227        // At table points
1228        assert!((interp_log_z(1e3, &z_table, &val_table) - 1.0).abs() < 1e-10);
1229        assert!((interp_log_z(1e6, &z_table, &val_table) - 4.0).abs() < 1e-10);
1230
1231        // Midpoint in log space between 1e3 and 1e4 is ~3162
1232        let mid = interp_log_z(3162.3, &z_table, &val_table);
1233        assert!((mid - 1.5).abs() < 0.01, "mid = {mid}, expected ~1.5");
1234
1235        // Outside bounds should be 0
1236        assert_eq!(interp_log_z(500.0, &z_table, &val_table), 0.0);
1237        assert_eq!(interp_log_z(2e6, &z_table, &val_table), 0.0);
1238    }
1239
1240    #[test]
1241    fn test_tabulated_heating_matches_burst() {
1242        // Create a tabulated heating rate that mimics a SingleBurst
1243        let cosmo = Cosmology::default();
1244        let z_h = 1e5_f64;
1245        let drho = 1e-5_f64;
1246        let sigma = (z_h * 0.04).max(100.0);
1247
1248        let burst = InjectionScenario::SingleBurst {
1249            z_h,
1250            delta_rho_over_rho: drho,
1251            sigma_z: sigma,
1252        };
1253
1254        // Tabulate dq/dz on a dense grid
1255        let n = 2000;
1256        let z_min = (z_h - 7.0 * sigma).max(100.0);
1257        let z_max = z_h + 7.0 * sigma;
1258        let mut z_table = Vec::with_capacity(n);
1259        let mut rate_table = Vec::with_capacity(n);
1260        for i in 0..n {
1261            let z = z_min + (z_max - z_min) * i as f64 / (n - 1) as f64;
1262            let dq_dz = burst.heating_rate_per_redshift(z, &cosmo).abs();
1263            z_table.push(z);
1264            rate_table.push(dq_dz);
1265        }
1266
1267        let tabulated = InjectionScenario::TabulatedHeating {
1268            z_table,
1269            rate_table,
1270        };
1271
1272        // Compare heating_rate at several z values
1273        for &z in &[z_h - 2.0 * sigma, z_h, z_h + 2.0 * sigma] {
1274            let ref_rate = burst.heating_rate(z, &cosmo);
1275            let tab_rate = tabulated.heating_rate(z, &cosmo);
1276            if ref_rate.abs() > 1e-100 {
1277                let err = (tab_rate - ref_rate).abs() / ref_rate.abs();
1278                assert!(
1279                    err < 0.01,
1280                    "z={z:.2e}: err={err:.2e} (ref={ref_rate:.4e}, tab={tab_rate:.4e})"
1281                );
1282            }
1283        }
1284    }
1285
1286    #[test]
1287    fn test_load_heating_table() {
1288        // Write a temp file and load it
1289        let dir = std::env::temp_dir();
1290        let path = dir.join("test_heating_table.csv");
1291        std::fs::write(&path, "z,dq_dz\n1e3,1.0e-10\n1e4,2.0e-10\n1e5,3.0e-10\n").unwrap();
1292
1293        let scenario = load_heating_table(path.to_str().unwrap()).unwrap();
1294        match &scenario {
1295            InjectionScenario::TabulatedHeating {
1296                z_table,
1297                rate_table,
1298            } => {
1299                assert_eq!(z_table.len(), 3);
1300                assert_eq!(rate_table.len(), 3);
1301                assert!((z_table[0] - 1e3).abs() < 1.0);
1302            }
1303            _ => panic!("Expected TabulatedHeating"),
1304        }
1305
1306        std::fs::remove_file(&path).ok();
1307    }
1308
1309    #[test]
1310    fn test_heating_rate_all_scenarios() {
1311        let cosmo = Cosmology::default();
1312        let z = 5e4;
1313
1314        // SingleBurst at z=z_h should have positive rate at the peak
1315        let z_h = 5e4;
1316        let drho = 1e-5;
1317        let sigma_z = 200.0;
1318        let burst = InjectionScenario::SingleBurst {
1319            z_h,
1320            delta_rho_over_rho: drho,
1321            sigma_z,
1322        };
1323        let rate = burst.heating_rate(z, &cosmo);
1324        assert!(
1325            rate > 0.0,
1326            "SingleBurst at peak should have positive rate: {rate:.4e}"
1327        );
1328        // At the peak z=z_h, analytical rate = drho * Gauss(0) * H(z_h) * (1+z_h)
1329        // Gauss(0) = 1/(sqrt(2π) σ_z) ≈ 1/(2.507 × 200) = 1.995e-3
1330        let gauss_peak = 1.0 / (2.0 * std::f64::consts::PI * sigma_z * sigma_z).sqrt();
1331        let expected_rate = drho * gauss_peak * cosmo.hubble(z_h) * (1.0 + z_h);
1332        assert!(
1333            (rate / expected_rate - 1.0).abs() < 1e-10,
1334            "SingleBurst peak rate = {rate:.6e}, expected = {expected_rate:.6e}"
1335        );
1336
1337        // DecayingParticle should have positive rate at any z
1338        let decay = InjectionScenario::DecayingParticle {
1339            f_x: 1e-5,
1340            gamma_x: 1e-12,
1341        };
1342        let rate = decay.heating_rate(z, &cosmo);
1343        assert!(
1344            rate > 0.0,
1345            "DecayingParticle should have positive rate: {rate:.4e}"
1346        );
1347
1348        // AnnihilatingDM should have positive rate
1349        let ann = InjectionScenario::AnnihilatingDM { f_ann: 2e-21 };
1350        let rate = ann.heating_rate(z, &cosmo);
1351        assert!(
1352            rate > 0.0,
1353            "AnnihilatingDM should have positive rate: {rate:.4e}"
1354        );
1355
1356        // P-wave should also be positive
1357        let pwave = InjectionScenario::AnnihilatingDMPWave { f_ann: 2e-31 };
1358        let rate = pwave.heating_rate(z, &cosmo);
1359        assert!(
1360            rate > 0.0,
1361            "AnnihilatingDMPWave should have positive rate: {rate:.4e}"
1362        );
1363
1364        // MonochromaticPhotonInjection: heating_rate should be 0 (energy is in photons)
1365        let mono = InjectionScenario::MonochromaticPhotonInjection {
1366            x_inj: 5.0,
1367            delta_n_over_n: 1e-5,
1368            sigma_x: 0.5,
1369            z_h: 3e5,
1370            sigma_z: 100.0,
1371        };
1372        let rate = mono.heating_rate(z, &cosmo);
1373        assert_eq!(
1374            rate, 0.0,
1375            "MonochromaticPhotonInjection heating_rate should be 0"
1376        );
1377
1378        // All should be finite at all redshifts
1379        let scenarios: Vec<&InjectionScenario> = vec![&burst, &decay, &ann, &pwave, &mono];
1380        for scenario in &scenarios {
1381            for &zz in &[1e3, 1e4, 1e5, 1e6] {
1382                let r = scenario.heating_rate(zz, &cosmo);
1383                assert!(r.is_finite(), "{} at z={zz:.0e}: NaN/Inf", scenario.name());
1384                let r_z = scenario.heating_rate_per_redshift(zz, &cosmo);
1385                assert!(
1386                    r_z.is_finite(),
1387                    "{} per_z at z={zz:.0e}: NaN/Inf",
1388                    scenario.name()
1389                );
1390            }
1391        }
1392    }
1393
1394    /// `photon_source_rate` for MonochromaticPhotonInjection must match its
1395    /// closed-form definition at the peak, not just be "> 1e-20".
1396    ///
1397    /// Oracle:             From Chluba 2015 / the scenario definition, at
1398    ///                     (x=x_inj, z=z_h) both Gaussians peak and the rate is
1399    ///                       r_peak = (ΔN/N) · G₂ / x_inj² ·
1400    ///                                [1/(σ_x √2π)] · [1/(σ_z √2π)] ·
1401    ///                                H(z_h) · (1+z_h)
1402    /// Expected:           r_peak computed from CODATA + Planck 2018 cosmology
1403    ///                     at z=3e5, x=5, σ_x=0.5, σ_z=100 → r_peak ≈ 3.7e6 s⁻¹
1404    /// Oracle uncertainty: 1e-14 relative (exact closed form, f64 roundoff only)
1405    /// Tolerance:          1e-10 relative
1406    ///
1407    /// Previous version asserted `rate > 1e-20` — 26 orders of magnitude
1408    /// below the physical value. A 12-OOM formula bug would have passed.
1409    #[test]
1410    fn test_photon_source_rate_nonzero() {
1411        let cosmo = Cosmology::default();
1412        let z_h = 3.0e5;
1413        let x_inj = 5.0;
1414        let delta_n_over_n = 1e-5;
1415        let sigma_x = 0.5;
1416        let sigma_z = 100.0;
1417
1418        let mono = InjectionScenario::MonochromaticPhotonInjection {
1419            x_inj,
1420            delta_n_over_n,
1421            sigma_x,
1422            z_h,
1423            sigma_z,
1424        };
1425        assert!(mono.has_photon_source());
1426
1427        // Peak rate: closed-form analytic value at (x=x_inj, z=z_h).
1428        let two_pi = std::f64::consts::TAU;
1429        let peak_gauss_x = 1.0 / (sigma_x * two_pi.sqrt());
1430        let peak_gauss_z = 1.0 / (sigma_z * two_pi.sqrt());
1431        let expected_peak = delta_n_over_n * G2_PLANCK / (x_inj * x_inj)
1432            * peak_gauss_x
1433            * peak_gauss_z
1434            * cosmo.hubble(z_h)
1435            * (1.0 + z_h);
1436
1437        let rate = mono.photon_source_rate(x_inj, z_h, &cosmo);
1438        let rel_err = (rate - expected_peak).abs() / expected_peak;
1439        assert!(
1440            rel_err < 1e-10,
1441            "photon_source_rate at peak: got {rate:.6e} vs analytic {expected_peak:.6e}, \
1442             rel_err={rel_err:.2e} (tol 1e-10)",
1443        );
1444
1445        // Off-peak suppression: at x = x_inj + 10σ_x the Gaussian factor is
1446        // exp(-50) ≈ 2e-22. Assert ratio below 1e-20 (room for σ_z factor too).
1447        let rate_far = mono.photon_source_rate(x_inj + 10.0 * sigma_x, z_h, &cosmo);
1448        assert!(
1449            rate_far / rate < 1e-20,
1450            "rate at x_inj + 10σ should be exp(-50)-suppressed: {rate_far:.2e} vs peak {rate:.2e}"
1451        );
1452
1453        // Variant coverage: DecayingParticlePhoton has a source; SingleBurst doesn't.
1454        let dp = InjectionScenario::DecayingParticlePhoton {
1455            x_inj_0: 5e3,
1456            f_inj: 1e-5,
1457            gamma_x: 1e-12,
1458        };
1459        assert!(dp.has_photon_source());
1460        let burst = InjectionScenario::SingleBurst {
1461            z_h: 5e4,
1462            delta_rho_over_rho: 1e-5,
1463            sigma_z: 200.0,
1464        };
1465        assert!(!burst.has_photon_source());
1466    }
1467
1468    #[test]
1469    fn test_characteristic_redshift_all_variants() {
1470        let burst = InjectionScenario::SingleBurst {
1471            z_h: 5e4,
1472            delta_rho_over_rho: 1e-5,
1473            sigma_z: 200.0,
1474        };
1475        let (z_h, z_start) = burst.characteristic_redshift().unwrap();
1476        assert!((z_h - 5e4).abs() < 1.0);
1477        assert!(z_start > z_h); // z_start = z_h + 7*sigma
1478
1479        let mono = InjectionScenario::MonochromaticPhotonInjection {
1480            x_inj: 5.0,
1481            delta_n_over_n: 1e-5,
1482            sigma_x: 0.5,
1483            z_h: 3e5,
1484            sigma_z: 100.0,
1485        };
1486        let (z_h, _) = mono.characteristic_redshift().unwrap();
1487        assert!((z_h - 3e5).abs() < 1.0);
1488
1489        // Continuous scenarios return None
1490        let dm = InjectionScenario::AnnihilatingDM { f_ann: 2e-21 };
1491        assert!(dm.characteristic_redshift().is_none());
1492    }
1493
1494    #[test]
1495    fn test_warn_strong_distortion() {
1496        // Small distortion: no warning
1497        let small = InjectionScenario::SingleBurst {
1498            z_h: 5e4,
1499            delta_rho_over_rho: 1e-5,
1500            sigma_z: 2000.0,
1501        };
1502        assert!(small.warn_strong_distortion().is_empty());
1503
1504        // Large distortion: should warn
1505        let large = InjectionScenario::SingleBurst {
1506            z_h: 5e4,
1507            delta_rho_over_rho: 0.1,
1508            sigma_z: 2000.0,
1509        };
1510        assert!(!large.warn_strong_distortion().is_empty());
1511
1512        // Photon injection with small amplitude: no warning
1513        let mono_small = InjectionScenario::MonochromaticPhotonInjection {
1514            x_inj: 5.0,
1515            delta_n_over_n: 1e-5,
1516            z_h: 5e4,
1517            sigma_z: 2000.0,
1518            sigma_x: 0.5,
1519        };
1520        assert!(mono_small.warn_strong_distortion().is_empty());
1521
1522        // Photon injection with large amplitude: should warn
1523        let mono_large = InjectionScenario::MonochromaticPhotonInjection {
1524            x_inj: 5.0,
1525            delta_n_over_n: 0.1,
1526            z_h: 5e4,
1527            sigma_z: 2000.0,
1528            sigma_x: 0.5,
1529        };
1530        assert!(!mono_large.warn_strong_distortion().is_empty());
1531
1532        // Other scenarios: no warning
1533        let dm = InjectionScenario::AnnihilatingDM { f_ann: 2e-24 };
1534        assert!(dm.warn_strong_distortion().is_empty());
1535
1536        // Photon injection at x_inj <= 150: no high-frequency warning
1537        let mono_150 = InjectionScenario::MonochromaticPhotonInjection {
1538            x_inj: 150.0,
1539            delta_n_over_n: 1e-5,
1540            z_h: 5e4,
1541            sigma_z: 2000.0,
1542            sigma_x: 7.5,
1543        };
1544        assert!(mono_150.warn_strong_distortion().is_empty());
1545
1546        // Photon injection at x_inj > 150: should warn
1547        let mono_200 = InjectionScenario::MonochromaticPhotonInjection {
1548            x_inj: 200.0,
1549            delta_n_over_n: 1e-5,
1550            z_h: 5e4,
1551            sigma_z: 2000.0,
1552            sigma_x: 10.0,
1553        };
1554        let warnings = mono_200.warn_strong_distortion();
1555        assert!(warnings.len() == 1);
1556        assert!(warnings[0].contains("x_inj"));
1557        assert!(warnings[0].contains("150"));
1558    }
1559
1560    #[test]
1561    fn test_tabulated_photon_source() {
1562        let cosmo = Cosmology::default();
1563
1564        // Create a simple 2D table mimicking a Gaussian photon source
1565        let z_table = vec![1e5, 2e5, 3e5];
1566        let x_grid = vec![1.0, 5.0, 10.0];
1567        let source_2d = vec![
1568            vec![0.0, 1e-10, 0.0], // z=1e5
1569            vec![0.0, 5e-10, 0.0], // z=2e5: peak
1570            vec![0.0, 1e-10, 0.0], // z=3e5
1571        ];
1572        let tab = InjectionScenario::TabulatedPhotonSource {
1573            z_table,
1574            x_grid,
1575            source_2d,
1576        };
1577
1578        // Should have photon source, no heating
1579        assert!(tab.has_photon_source());
1580        assert_eq!(tab.heating_rate(2e5, &cosmo), 0.0);
1581
1582        // Photon source rate at peak (z=2e5, x=5) should be positive
1583        let rate = tab.photon_source_rate(5.0, 2e5, &cosmo);
1584        assert!(
1585            rate > 0.0 && rate.is_finite(),
1586            "TabulatedPhotonSource rate at peak: {rate:.4e}"
1587        );
1588
1589        // Rate at x=1 should be zero (no source at x=1 in the table)
1590        let rate_off = tab.photon_source_rate(1.0, 2e5, &cosmo);
1591        assert!(
1592            rate_off < rate * 0.01,
1593            "Rate at x=1 should be much less than peak: {rate_off:.4e} vs {rate:.4e}"
1594        );
1595
1596        // Validation should pass
1597        assert!(tab.validate().is_ok());
1598
1599        // Name check
1600        assert_eq!(tab.name(), "tabulated-photon");
1601    }
1602
1603    #[test]
1604    fn test_tabulated_photon_source_bilinear_interpolation() {
1605        // Test that interp_2d performs correct bilinear interpolation between grid points.
1606        // Grid: z_table=[1e5, 2e5], x_grid=[5.0, 10.0]
1607        // source_2d = [[f00, f01], [f10, f11]] = [[1.0, 0.0], [3.0, 0.0]]
1608        //
1609        // At the midpoint (z=1.5e5, x=7.5):
1610        //   tz = 0.5,  tx = 0.5
1611        //   bilinear = (1-tz)*((1-tx)*f00 + tx*f01) + tz*((1-tx)*f10 + tx*f11)
1612        //            = 0.5*(0.5*1 + 0.5*0) + 0.5*(0.5*3 + 0.5*0)
1613        //            = 0.5*0.5 + 0.5*1.5 = 0.25 + 0.75 = 1.0
1614        let cosmo = Cosmology::default();
1615        let z_table = vec![1e5, 2e5];
1616        let x_grid = vec![5.0, 10.0];
1617        let source_2d = vec![
1618            vec![1.0, 0.0], // z=1e5
1619            vec![3.0, 0.0], // z=2e5
1620        ];
1621        let tab = InjectionScenario::TabulatedPhotonSource {
1622            z_table,
1623            x_grid,
1624            source_2d,
1625        };
1626
1627        // At midpoint, expected dn_dz = 1.0 before Hubble conversion
1628        // photon_source_rate multiplies by H(z)*(1+z), so we divide it back out.
1629        let z_mid = 1.5e5;
1630        let h_factor = cosmo.hubble(z_mid) * (1.0 + z_mid);
1631        let rate_mid = tab.photon_source_rate(7.5, z_mid, &cosmo);
1632        let dn_dz = rate_mid / h_factor;
1633        assert!(
1634            (dn_dz - 1.0).abs() < 1e-10,
1635            "Bilinear interpolation at midpoint: dn_dz={dn_dz:.6}, expected 1.0"
1636        );
1637
1638        // At z=1e5, x=5.0 (exact grid point): expected 1.0
1639        let rate_corner = tab.photon_source_rate(5.0, 1e5, &cosmo);
1640        let h_corner = cosmo.hubble(1e5) * (1.0 + 1e5);
1641        let dn_dz_corner = rate_corner / h_corner;
1642        assert!(
1643            (dn_dz_corner - 1.0).abs() < 1e-10,
1644            "Exact grid point at z=1e5, x=5: dn_dz={dn_dz_corner:.6}, expected 1.0"
1645        );
1646
1647        // Outside the table range: should return 0
1648        assert_eq!(
1649            tab.photon_source_rate(5.0, 3e5, &cosmo),
1650            0.0,
1651            "Outside z range should return 0"
1652        );
1653        assert_eq!(
1654            tab.photon_source_rate(20.0, 1.5e5, &cosmo),
1655            0.0,
1656            "Outside x range should return 0"
1657        );
1658    }
1659
1660    #[test]
1661    fn test_custom_injection_scenario() {
1662        let cosmo = Cosmology::default();
1663
1664        // Custom scenario: constant heating rate of 1e-15 /s
1665        let custom =
1666            InjectionScenario::Custom(Box::new(|_z: f64, _cosmo: &Cosmology| -> f64 { 1e-15 }));
1667
1668        let rate = custom.heating_rate(1e5, &cosmo);
1669        assert!(
1670            (rate - 1e-15).abs() < 1e-25,
1671            "Custom heating rate should be 1e-15: got {rate}"
1672        );
1673
1674        // Should not have photon source
1675        assert!(!custom.has_photon_source());
1676
1677        // Name check
1678        assert_eq!(custom.name(), "custom");
1679
1680        // Rate at different z should be the same (constant)
1681        let rate2 = custom.heating_rate(5e5, &cosmo);
1682        assert!(
1683            (rate2 - rate).abs() < 1e-25,
1684            "Constant custom rate: {rate} vs {rate2}"
1685        );
1686    }
1687}