1use crate::constants::*;
17use crate::cosmology::Cosmology;
18use crate::grid::RefinementZone;
19use crate::spectrum::planck;
20
21fn vacuum_survival(z: f64, gamma_x: f64, cosmo: &Cosmology) -> f64 {
25 (-gamma_x * cosmo.cosmic_time(z)).exp()
26}
27
28#[non_exhaustive]
30pub enum InjectionScenario {
31 SingleBurst {
33 z_h: f64,
35 delta_rho_over_rho: f64,
37 sigma_z: f64,
39 },
40
41 DecayingParticle {
43 f_x: f64,
45 gamma_x: f64,
47 },
48
49 AnnihilatingDM {
51 f_ann: f64,
56 },
57
58 AnnihilatingDMPWave {
63 f_ann: f64,
67 },
68
69 MonochromaticPhotonInjection {
87 x_inj: f64,
89 delta_n_over_n: f64,
91 z_h: f64,
93 sigma_z: f64,
95 sigma_x: f64,
97 },
98
99 DecayingParticlePhoton {
122 x_inj_0: f64,
124 f_inj: f64,
127 gamma_x: f64,
129 },
130
131 DarkPhotonResonance {
147 epsilon: f64,
149 m_ev: f64,
151 },
152
153 TabulatedHeating {
163 z_table: Vec<f64>,
165 rate_table: Vec<f64>,
167 },
168
169 TabulatedPhotonSource {
174 z_table: Vec<f64>,
176 x_grid: Vec<f64>,
178 source_2d: Vec<Vec<f64>>,
180 },
181
182 Custom(Box<dyn Fn(f64, &Cosmology) -> f64 + Send + Sync>),
184}
185
186fn 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 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
212fn 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 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 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
271pub 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 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 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
329pub 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 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 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 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 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 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 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 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 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 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 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 let t = cosmo.cosmic_time(z);
730 let n_h = cosmo.n_h(z);
731 let rho_gamma = cosmo.rho_gamma(z);
732
733 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 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 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 0.0
765 }
766
767 InjectionScenario::DecayingParticlePhoton { .. } => {
768 0.0
771 }
772
773 InjectionScenario::DarkPhotonResonance { .. } => {
774 0.0
777 }
778
779 InjectionScenario::TabulatedHeating {
780 z_table,
781 rate_table,
782 } => {
783 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 0.0
792 }
793
794 InjectionScenario::Custom(f) => f(z, cosmo),
795 }
796 }
797
798 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 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 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 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 if sigma_x < 1e-30 || (x - x_inj).abs() > 5.0 * sigma_x {
846 return 0.0;
847 }
848
849 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 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 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 pub fn has_photon_source(&self) -> bool {
891 matches!(
892 self,
893 InjectionScenario::MonochromaticPhotonInjection { .. }
894 | InjectionScenario::DecayingParticlePhoton { .. }
895 | InjectionScenario::TabulatedPhotonSource { .. }
896 )
897 }
898
899 pub fn refinement_zones(&self) -> Vec<RefinementZone> {
905 match self {
906 InjectionScenario::MonochromaticPhotonInjection { x_inj, sigma_x, .. } => {
907 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 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 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 _ => None,
954 }
955 }
956
957 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 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 pub fn suggested_x_min(&self) -> Option<f64> {
1000 match self {
1001 InjectionScenario::MonochromaticPhotonInjection { x_inj, .. } => {
1002 let suggested = (x_inj / 100.0).max(1e-7);
1005 if suggested < 1e-4 {
1006 Some(suggested)
1007 } else {
1008 None }
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 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 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 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 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 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 pub fn heating_rate_per_redshift(&self, z: f64, cosmo: &Cosmology) -> f64 {
1181 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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); 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 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 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 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 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 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 let dm = InjectionScenario::AnnihilatingDM { f_ann: 2e-24 };
1534 assert!(dm.warn_strong_distortion().is_empty());
1535
1536 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 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 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], vec![0.0, 5e-10, 0.0], vec![0.0, 1e-10, 0.0], ];
1572 let tab = InjectionScenario::TabulatedPhotonSource {
1573 z_table,
1574 x_grid,
1575 source_2d,
1576 };
1577
1578 assert!(tab.has_photon_source());
1580 assert_eq!(tab.heating_rate(2e5, &cosmo), 0.0);
1581
1582 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 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 assert!(tab.validate().is_ok());
1598
1599 assert_eq!(tab.name(), "tabulated-photon");
1601 }
1602
1603 #[test]
1604 fn test_tabulated_photon_source_bilinear_interpolation() {
1605 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], vec![3.0, 0.0], ];
1621 let tab = InjectionScenario::TabulatedPhotonSource {
1622 z_table,
1623 x_grid,
1624 source_2d,
1625 };
1626
1627 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 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 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 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 assert!(!custom.has_photon_source());
1676
1677 assert_eq!(custom.name(), "custom");
1679
1680 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}