1use std::collections::HashMap;
17
18use crate::energy_injection::InjectionScenario;
19use crate::output::{
20 GreensResult, OutputFormat, PhotonSweepBatchResult, PhotonSweepResult, PhotonSweepRow,
21 SweepResult, SweepRow,
22};
23use crate::prelude::*;
24
25#[derive(Debug)]
27pub enum Command {
28 Solve(SolveOpts),
30 Sweep(SweepOpts),
32 Greens(GreensOpts),
34 Info(InfoOpts),
36 PhysicsHash,
38 PhotonSweep(PhotonSweepOpts),
40 PhotonSweepBatch(PhotonSweepBatchOpts),
42 Help,
44}
45
46#[derive(Debug)]
48pub struct SolveOpts {
49 pub injection_type: String,
53 pub params: HashMap<String, String>,
56 pub solver: SolverOpts,
58 pub cosmo: CosmoOpts,
60 pub output: OutputOpts,
62}
63
64#[derive(Debug)]
66pub struct SweepOpts {
67 pub z_injections: Option<Vec<f64>>,
70 pub delta_rho: f64,
72 pub params: HashMap<String, String>,
74 pub solver: SolverOpts,
76 pub cosmo: CosmoOpts,
78 pub output: OutputOpts,
80}
81
82#[derive(Debug)]
84pub struct GreensOpts {
85 pub z_h: f64,
87 pub delta_rho: f64,
89 pub output: OutputOpts,
91}
92
93#[derive(Debug)]
95pub struct PhotonSweepOpts {
96 pub x_inj: f64,
98 pub delta_n_over_n: f64,
100 pub sigma_x: Option<f64>,
103 pub z_injections: Option<Vec<f64>>,
105 pub solver: SolverOpts,
107 pub cosmo: CosmoOpts,
109 pub output: OutputOpts,
111}
112
113#[derive(Debug)]
115pub struct PhotonSweepBatchOpts {
116 pub x_inj_values: Vec<f64>,
119 pub delta_n_over_n: f64,
122 pub sigma_x: Option<f64>,
124 pub z_injections: Option<Vec<f64>>,
126 pub solver: SolverOpts,
128 pub cosmo: CosmoOpts,
130 pub output: OutputOpts,
132}
133
134#[derive(Debug)]
136pub struct InfoOpts {
137 pub cosmology: String,
140}
141
142#[derive(Debug)]
144pub struct SolverOpts {
145 pub z_start: Option<f64>,
147 pub z_end: f64,
149 pub dy_max: Option<f64>,
151 pub dtau_max: Option<f64>,
153 pub n_points: Option<usize>,
156 pub disable_dcbr: bool,
158 pub split_dcbr: bool,
160 pub cn_dcbr: bool,
163 pub number_conserving: bool,
166 pub nc_stride: Option<usize>,
169 pub nc_z_min: Option<f64>,
172 pub production_grid: bool,
174 pub dn_planck: Option<f64>,
177 pub no_auto_refine: bool,
180 pub n_threads: Option<usize>,
182 pub dtau_max_photon_source: Option<f64>,
186}
187
188#[derive(Debug)]
190pub struct CosmoOpts {
191 pub omega_b: Option<f64>,
195 pub omega_m: Option<f64>,
199 pub h: Option<f64>,
201 pub n_eff: Option<f64>,
203 pub y_p: Option<f64>,
205 pub t_cmb: Option<f64>,
207 pub preset: Option<String>,
210}
211
212#[derive(Debug)]
214pub struct OutputOpts {
215 pub format: OutputFormat,
217 pub output_path: Option<String>,
220}
221
222impl Default for SolverOpts {
223 fn default() -> Self {
224 SolverOpts {
225 z_start: None,
226 z_end: 500.0,
227 dy_max: None,
228 dtau_max: None,
229 n_points: None,
230 disable_dcbr: false,
231 split_dcbr: false,
232 cn_dcbr: false,
233 number_conserving: true,
234 nc_stride: None,
235 nc_z_min: None,
236 production_grid: false,
237 dn_planck: None,
238 no_auto_refine: false,
239 n_threads: None,
240 dtau_max_photon_source: None,
241 }
242 }
243}
244
245impl Default for CosmoOpts {
246 fn default() -> Self {
247 CosmoOpts {
248 omega_b: None,
249 omega_m: None,
250 h: None,
251 n_eff: None,
252 y_p: None,
253 t_cmb: None,
254 preset: None,
255 }
256 }
257}
258
259impl Default for OutputOpts {
260 fn default() -> Self {
261 OutputOpts {
262 format: OutputFormat::Json,
263 output_path: None,
264 }
265 }
266}
267
268const SUBCOMMANDS: &[&str] = &[
270 "solve",
271 "sweep",
272 "photon-sweep",
273 "photon-sweep-batch",
274 "greens",
275 "info",
276 "physics-hash",
277 "help",
278];
279
280pub fn parse_command(args: &[String]) -> Result<Command, String> {
282 if args.is_empty() {
283 return Ok(Command::Help);
284 }
285
286 let first = args[0].as_str();
287
288 if !SUBCOMMANDS.contains(&first) {
290 return Err(format!(
291 "Unknown subcommand: '{first}'. Valid: solve, sweep, greens, info, help"
292 ));
293 }
294
295 match first {
296 "help" => Ok(Command::Help),
297 "physics-hash" => Ok(Command::PhysicsHash),
298 "info" => {
299 let map = parse_flat_args(&args[1..]);
300 Ok(Command::Info(InfoOpts {
301 cosmology: map
302 .get("--cosmology")
303 .cloned()
304 .unwrap_or_else(|| "default".to_string()),
305 }))
306 }
307 "greens" => {
308 let map = parse_flat_args(&args[1..]);
309 let z_h: f64 = map
310 .get("--z-h")
311 .ok_or("--z-h required for greens subcommand")?
312 .parse()
313 .map_err(|_| "Invalid --z-h")?;
314 let delta_rho = parse_f64_or(&map, "--delta-rho", 1e-5)?;
315 let output = parse_output_opts(&map)?;
316 Ok(Command::Greens(GreensOpts {
317 z_h,
318 delta_rho,
319 output,
320 }))
321 }
322 "sweep" => {
323 let map = parse_flat_args(&args[1..]);
324 let z_injections = map
325 .get("--z-injections")
326 .map(|s| parse_float_list(s))
327 .transpose()?;
328 let delta_rho = parse_f64_or(&map, "--delta-rho", 1e-5)?;
329 let solver = parse_solver_opts(&map)?;
330 let cosmo = parse_cosmo_opts(&map)?;
331 let output = parse_output_opts(&map)?;
332 Ok(Command::Sweep(SweepOpts {
333 z_injections,
334 delta_rho,
335 params: map,
336 solver,
337 cosmo,
338 output,
339 }))
340 }
341 "photon-sweep" => {
342 let map = parse_flat_args(&args[1..]);
343 let x_inj: f64 = map
344 .get("--x-inj")
345 .ok_or("--x-inj required for photon-sweep subcommand")?
346 .parse()
347 .map_err(|_| "Invalid --x-inj")?;
348 let delta_n_over_n = parse_f64_or(&map, "--delta-n-over-n", 1e-5)?;
349 let sigma_x: Option<f64> = map
350 .get("--sigma-x")
351 .map(|s| s.parse().map_err(|_| "Invalid --sigma-x"))
352 .transpose()?;
353 let z_injections = map
354 .get("--z-injections")
355 .map(|s| parse_float_list(s))
356 .transpose()?;
357 let solver = parse_solver_opts(&map)?;
358 let cosmo = parse_cosmo_opts(&map)?;
359 let output = parse_output_opts(&map)?;
360 Ok(Command::PhotonSweep(PhotonSweepOpts {
361 x_inj,
362 delta_n_over_n,
363 sigma_x,
364 z_injections,
365 solver,
366 cosmo,
367 output,
368 }))
369 }
370 "photon-sweep-batch" => {
371 let map = parse_flat_args(&args[1..]);
372 let x_inj_str = map
373 .get("--x-inj-values")
374 .ok_or("--x-inj-values required for photon-sweep-batch subcommand")?;
375 let x_inj_values = parse_float_list(x_inj_str)?;
376 if x_inj_values.is_empty() {
377 return Err("--x-inj-values must contain at least one value".to_string());
378 }
379 let delta_n_over_n = parse_f64_or(&map, "--delta-n-over-n", 1e-5)?;
380 let sigma_x: Option<f64> = map
381 .get("--sigma-x")
382 .map(|s| s.parse().map_err(|_| "Invalid --sigma-x"))
383 .transpose()?;
384 let z_injections = map
385 .get("--z-injections")
386 .map(|s| parse_float_list(s))
387 .transpose()?;
388 let solver = parse_solver_opts(&map)?;
389 let cosmo = parse_cosmo_opts(&map)?;
390 let output = parse_output_opts(&map)?;
391 Ok(Command::PhotonSweepBatch(PhotonSweepBatchOpts {
392 x_inj_values,
393 delta_n_over_n,
394 sigma_x,
395 z_injections,
396 solver,
397 cosmo,
398 output,
399 }))
400 }
401 "solve" => {
402 let injection_type = args
404 .get(1)
405 .ok_or("solve requires an injection type (e.g., single-burst, decaying-particle)")?
406 .clone();
407 if injection_type.starts_with("--") {
408 return Err(format!(
409 "solve requires an injection type as second argument, got '{injection_type}'"
410 ));
411 }
412 let map = parse_flat_args(&args[2..]);
413 let solver = parse_solver_opts(&map)?;
414 let cosmo = parse_cosmo_opts(&map)?;
415 let output = parse_output_opts(&map)?;
416 Ok(Command::Solve(SolveOpts {
417 injection_type,
418 params: map,
419 solver,
420 cosmo,
421 output,
422 }))
423 }
424 _ => unreachable!(),
425 }
426}
427
428pub fn parse_flat_args(args: &[String]) -> HashMap<String, String> {
430 let mut map = HashMap::new();
431 let mut i = 0;
432 while i < args.len() {
433 if args[i].starts_with("--") {
434 let key = args[i].clone();
435 if i + 1 < args.len() && !args[i + 1].starts_with("--") {
436 map.insert(key, args[i + 1].clone());
437 i += 2;
438 } else {
439 map.insert(key, String::new());
440 i += 1;
441 }
442 } else {
443 i += 1;
444 }
445 }
446 map
447}
448
449fn parse_f64_or(map: &HashMap<String, String>, key: &str, default: f64) -> Result<f64, String> {
450 match map.get(key) {
451 Some(s) => s.parse().map_err(|_| format!("Invalid {key}")),
452 None => Ok(default),
453 }
454}
455
456fn parse_float_list(s: &str) -> Result<Vec<f64>, String> {
457 s.split(',')
458 .filter(|s| !s.is_empty())
459 .map(|s| {
460 s.trim()
461 .parse::<f64>()
462 .map_err(|_| format!("Invalid number: '{s}'"))
463 })
464 .collect()
465}
466
467fn parse_solver_opts(map: &HashMap<String, String>) -> Result<SolverOpts, String> {
468 Ok(SolverOpts {
469 z_start: map
470 .get("--z-start")
471 .map(|s| s.parse().map_err(|_| "Invalid --z-start"))
472 .transpose()?,
473 z_end: parse_f64_or(map, "--z-end", 500.0)?,
474 dy_max: map
475 .get("--dy-max")
476 .map(|s| s.parse().map_err(|_| "Invalid --dy-max"))
477 .transpose()?,
478 dtau_max: map
479 .get("--dtau-max")
480 .map(|s| s.parse().map_err(|_| "Invalid --dtau-max"))
481 .transpose()?,
482 n_points: map
483 .get("--n-points")
484 .map(|s| s.parse().map_err(|_| "Invalid --n-points"))
485 .transpose()?,
486 disable_dcbr: map.contains_key("--no-dcbr"),
487 split_dcbr: map.contains_key("--split-dcbr"),
488 cn_dcbr: map.contains_key("--cn-dcbr"),
489 number_conserving: !map.contains_key("--no-number-conserving"),
490 nc_stride: map
491 .get("--nc-stride")
492 .map(|s| s.parse().map_err(|_| "Invalid --nc-stride"))
493 .transpose()?,
494 nc_z_min: map
495 .get("--nc-z-min")
496 .map(|s| s.parse().map_err(|_| "Invalid --nc-z-min"))
497 .transpose()?,
498 production_grid: map.contains_key("--production-grid"),
499 dn_planck: map
500 .get("--dn-planck")
501 .map(|s| s.parse().map_err(|_| "Invalid --dn-planck"))
502 .transpose()?,
503 no_auto_refine: map.contains_key("--no-auto-refine"),
504 n_threads: map
505 .get("--threads")
506 .map(|s| s.parse().map_err(|_| "Invalid --threads"))
507 .transpose()?,
508 dtau_max_photon_source: map
509 .get("--dtau-max-photon-source")
510 .map(|s| s.parse().map_err(|_| "Invalid --dtau-max-photon-source"))
511 .transpose()?,
512 })
513}
514
515fn parse_optional_f64(map: &HashMap<String, String>, key: &str) -> Result<Option<f64>, String> {
517 match map.get(key) {
518 Some(s) => s
519 .parse::<f64>()
520 .map(Some)
521 .map_err(|_| format!("Invalid value for {key}: '{s}' (expected a number)")),
522 None => Ok(None),
523 }
524}
525
526fn parse_cosmo_opts(map: &HashMap<String, String>) -> Result<CosmoOpts, String> {
527 if map.contains_key("--omega-cdm") {
528 return Err(
529 "--omega-cdm is no longer accepted. Use --omega-m (fractional total \
530 matter Ω_m); the CLI converts to ω_cdm = (Ω_m − Ω_b) h² internally."
531 .to_string(),
532 );
533 }
534 Ok(CosmoOpts {
535 omega_b: parse_optional_f64(map, "--omega-b")?,
536 omega_m: parse_optional_f64(map, "--omega-m")?,
537 h: parse_optional_f64(map, "--h")?,
538 n_eff: parse_optional_f64(map, "--n-eff")?,
539 y_p: parse_optional_f64(map, "--y-p")?,
540 t_cmb: parse_optional_f64(map, "--t-cmb")?,
541 preset: map.get("--cosmology").cloned(),
542 })
543}
544
545fn parse_output_opts(map: &HashMap<String, String>) -> Result<OutputOpts, String> {
546 let format = match map.get("--format") {
547 Some(s) => OutputFormat::from_str(s)?,
548 None => OutputFormat::Json,
549 };
550 let output_path = map.get("--output").cloned();
551 Ok(OutputOpts {
552 format,
553 output_path,
554 })
555}
556
557pub fn build_cosmology(opts: &CosmoOpts) -> Result<crate::cosmology::Cosmology, String> {
559 if let Some(ref preset) = opts.preset {
561 match preset.as_str() {
562 "planck2015" => return Ok(crate::cosmology::Cosmology::planck2015()),
563 "planck2018" => return Ok(crate::cosmology::Cosmology::planck2018()),
564 "default" => return Ok(crate::cosmology::Cosmology::default()),
565 _ => {
566 return Err(format!(
567 "Unknown cosmology preset '{preset}'. Valid presets: default, planck2015, planck2018"
568 ));
569 }
570 }
571 }
572
573 match (opts.omega_b, opts.omega_m) {
578 (Some(_), None) | (None, Some(_)) => {
579 return Err(
580 "--omega-b and --omega-m must be provided together (Ω_cdm is \
581 computed from the difference). Pass both, or neither (to keep \
582 the preset matter sector)."
583 .to_string(),
584 );
585 }
586 _ => {}
587 }
588 let defaults = crate::cosmology::Cosmology::default();
589 let h = opts.h.unwrap_or(defaults.h);
590 let h2 = h * h;
591 let (omega_b_phys, omega_cdm_phys) = match (opts.omega_b, opts.omega_m) {
592 (Some(omega_b_frac), Some(omega_m_frac)) => {
593 if !(0.0..=1.0).contains(&omega_b_frac) {
594 return Err(format!(
595 "--omega-b is fractional Ω_b ∈ [0, 1]; got {omega_b_frac}. \
596 Pass the fraction (e.g. 0.044), not the physical density Ω_b h²."
597 ));
598 }
599 if !(0.0..=1.0).contains(&omega_m_frac) {
600 return Err(format!(
601 "--omega-m is fractional total matter Ω_m ∈ [0, 1]; got {omega_m_frac}."
602 ));
603 }
604 if omega_m_frac < omega_b_frac {
605 return Err(format!(
606 "Ω_m ({omega_m_frac}) must be ≥ Ω_b ({omega_b_frac})."
607 ));
608 }
609 (omega_b_frac * h2, (omega_m_frac - omega_b_frac) * h2)
610 }
611 _ => (defaults.omega_b, defaults.omega_cdm),
612 };
613 crate::cosmology::Cosmology::new(
614 opts.t_cmb.unwrap_or(defaults.t_cmb),
615 omega_b_phys,
616 omega_cdm_phys,
617 h,
618 opts.n_eff.unwrap_or(defaults.n_eff),
619 opts.y_p.unwrap_or(defaults.y_p),
620 )
621}
622
623pub fn print_help() {
625 eprintln!("spectroxide: CMB spectral distortion solver");
626 eprintln!();
627 eprintln!("USAGE:");
628 eprintln!(" spectroxide solve <injection-type> [options]");
629 eprintln!(" spectroxide sweep [options]");
630 eprintln!(" spectroxide photon-sweep --x-inj <x> [options]");
631 eprintln!(" spectroxide photon-sweep-batch --x-inj-values <x1,x2,...> [options]");
632 eprintln!(" spectroxide greens --z-h <z> [options]");
633 eprintln!(" spectroxide info [--cosmology <preset>]");
634 eprintln!(" spectroxide physics-hash");
635 eprintln!(" spectroxide help");
636 eprintln!();
637 eprintln!("INJECTION TYPES:");
638 eprintln!(" single-burst --z-h, --delta-rho, [--sigma-z]");
639 eprintln!(" decaying-particle --f-x, --gamma-x");
640 eprintln!(" annihilating-dm --f-ann");
641 eprintln!(" annihilating-dm-pwave --f-ann");
642 eprintln!(" monochromatic-photon --x-inj, --delta-n-over-n, --z-h");
643 eprintln!(" decaying-particle-photon --x-inj-0, --f-inj, --gamma-x");
644 eprintln!(" dark-photon-resonance --epsilon, --m-ev");
645 eprintln!(" tabulated-heating --heating-table PATH (CSV: z,dq_dz)");
646 eprintln!(" tabulated-photon --photon-table PATH (CSV: z,x1,...,xN)");
647 eprintln!();
648 eprintln!("SOLVER OPTIONS:");
649 eprintln!(" --z-start <z> Starting redshift");
650 eprintln!(" --z-end <z> Final redshift (default 500)");
651 eprintln!(" --delta-rho <val> Fractional energy injection (for solve)");
652 eprintln!(" --dy-max <val> Max Kompaneets step size");
653 eprintln!(
654 " --dtau-max <val> Max Compton optical depth per step (default 10; use 3 for <0.1% precision)"
655 );
656 eprintln!(
657 " --dtau-max-photon-source <val> Max dtau per step near photon source (default 1.0)"
658 );
659 eprintln!(" --n-points <n> Grid points");
660 eprintln!(" --production-grid Use 4000-point production grid");
661 eprintln!(" --no-dcbr Disable DC/BR");
662 eprintln!(" --split-dcbr Operator-split DC/BR");
663 eprintln!(" --no-number-conserving Disable NC T-shift subtraction (on by default)");
664 eprintln!(" --nc-stride <n> NC stripping stride (steps between strips)");
665 eprintln!(" --nc-z-min <z> NC stripping minimum redshift (default 5e4)");
666 eprintln!(" --dn-planck <val> Initial Planck perturbation amplitude");
667 eprintln!(" --sigma-z <val> Temporal width for burst injection");
668 eprintln!(" --sigma-x <val> Spectral width for photon injection");
669 eprintln!(" --no-auto-refine Disable automatic grid refinement");
670 eprintln!(" --threads <n> Number of threads for parallel sweeps");
671 eprintln!(" --format json|csv|table Output format (default json)");
672 eprintln!(" --output <path> Write output to file (default: stdout)");
673 eprintln!();
674 eprintln!("COSMOLOGY:");
675 eprintln!(" --cosmology <preset> default, planck2015, planck2018");
676 eprintln!(" --omega-b <Ω_b> Fractional baryon density (pass with --omega-m)");
677 eprintln!(" --omega-m <Ω_m> Fractional total matter density");
678 eprintln!(" --h, --n-eff, --y-p, --t-cmb");
679 eprintln!();
680 eprintln!("EXAMPLES:");
681 eprintln!(" spectroxide solve single-burst --z-h 2e5 --delta-rho 1e-5");
682 eprintln!(" spectroxide solve decaying-particle --f-x 1e5 --gamma-x 2e5");
683 eprintln!(" spectroxide solve dark-photon-resonance --epsilon 1e-9 --m-ev 1e-7");
684 eprintln!(" spectroxide greens --z-h 2e5");
685 eprintln!(" spectroxide info --cosmology planck2018");
686}
687
688pub fn print_info(opts: &InfoOpts) -> Result<(), String> {
690 let cosmo = build_cosmology(&CosmoOpts {
691 preset: Some(opts.cosmology.clone()),
692 ..CosmoOpts::default()
693 })?;
694 println!("Cosmology: {}", opts.cosmology);
695 println!(" h = {}", cosmo.h);
696 println!(" Ω_b = {:.6}", cosmo.omega_b_frac());
697 println!(
698 " Ω_m = {:.6} (Ω_cdm = {:.6})",
699 cosmo.omega_b_frac() + cosmo.omega_cdm_frac(),
700 cosmo.omega_cdm_frac()
701 );
702 println!(" Y_p = {:.4}", cosmo.y_p);
703 println!(" T_CMB = {:.4} K", cosmo.t_cmb);
704 println!(" N_eff = {:.3}", cosmo.n_eff);
705 println!(" z_eq = {:.1}", cosmo.z_eq());
706 println!(
707 " H_0 = {:.2} km/s/Mpc",
708 cosmo.hubble(0.0) * 3.086e22 / 1e3
709 );
710 Ok(())
711}
712
713pub fn build_injection_scenario(
717 injection_type: &str,
718 args: &HashMap<String, String>,
719 delta_rho: f64,
720) -> Result<InjectionScenario, String> {
721 let get_required = |key: &str| -> Result<f64, String> {
722 args.get(key)
723 .ok_or_else(|| format!("{key} required for {injection_type}"))?
724 .parse()
725 .map_err(|_| format!("Invalid {key}"))
726 };
727 let get_optional = |key: &str, default: f64| -> Result<f64, String> {
728 match args.get(key) {
729 Some(s) => s.parse().map_err(|_| format!("Invalid {key}")),
730 None => Ok(default),
731 }
732 };
733
734 match injection_type {
735 "decaying-particle" => {
736 let f_x = get_required("--f-x")?;
737 let gamma_x = get_required("--gamma-x")?;
738 Ok(InjectionScenario::DecayingParticle { f_x, gamma_x })
739 }
740 "annihilating-dm" => {
741 let f_ann = get_required("--f-ann")?;
742 Ok(InjectionScenario::AnnihilatingDM { f_ann })
743 }
744 "annihilating-dm-pwave" => {
745 let f_ann = get_required("--f-ann")?;
746 Ok(InjectionScenario::AnnihilatingDMPWave { f_ann })
747 }
748 "single-burst" => {
749 let z_h = get_required("--z-h")?;
750 let sigma_z = get_optional("--sigma-z", (z_h * 0.04_f64).max(100.0))?;
751 Ok(InjectionScenario::SingleBurst {
752 z_h,
753 delta_rho_over_rho: delta_rho,
754 sigma_z,
755 })
756 }
757 "monochromatic-photon" => {
758 let x_inj = get_required("--x-inj")?;
759 let delta_n_over_n = get_required("--delta-n-over-n")?;
760 let z_h = get_required("--z-h")?;
761 let sigma_z = get_optional("--sigma-z", (z_h * 0.04_f64).max(100.0))?;
762 let sigma_x = get_optional("--sigma-x", x_inj * 0.05)?;
763 Ok(InjectionScenario::MonochromaticPhotonInjection {
764 x_inj,
765 delta_n_over_n,
766 z_h,
767 sigma_z,
768 sigma_x,
769 })
770 }
771 "decaying-particle-photon" => {
772 let x_inj_0 = get_required("--x-inj-0")?;
773 let f_inj = get_required("--f-inj")?;
774 let gamma_x = get_required("--gamma-x")?;
775 Ok(InjectionScenario::DecayingParticlePhoton {
776 x_inj_0,
777 f_inj,
778 gamma_x,
779 })
780 }
781 "dark-photon-resonance" => {
782 let epsilon = get_required("--epsilon")?;
783 let m_ev = get_required("--m-ev")?;
784 Ok(InjectionScenario::DarkPhotonResonance { epsilon, m_ev })
785 }
786 "tabulated-heating" => {
787 let path = args
788 .get("--heating-table")
789 .ok_or("--heating-table PATH required for tabulated-heating")?;
790 crate::energy_injection::load_heating_table(path)
791 }
792 "tabulated-photon" => {
793 let path = args
794 .get("--photon-table")
795 .ok_or("--photon-table PATH required for tabulated-photon")?;
796 crate::energy_injection::load_photon_source_table(path)
797 }
798 _ => Err(format!(
799 "Unknown injection type: '{injection_type}'. \
800 Valid: single-burst, \
801 decaying-particle, decaying-particle-photon, \
802 annihilating-dm, annihilating-dm-pwave, monochromatic-photon, \
803 tabulated-heating, tabulated-photon"
804 )),
805 }
806}
807
808pub fn execute_greens(opts: &GreensOpts) -> Result<GreensResult, String> {
810 let z_h = opts.z_h;
811 let delta_rho = opts.delta_rho;
812 let sigma = (z_h * 0.04_f64).max(100.0);
813
814 let x_grid: Vec<f64> = {
815 let gc = GridConfig::default();
816 crate::grid::FrequencyGrid::new(&gc).x
817 };
818
819 let dq_dz = |z: f64| -> f64 {
820 delta_rho * (-(z - z_h).powi(2) / (2.0 * sigma * sigma)).exp()
821 / (2.0 * std::f64::consts::PI * sigma * sigma).sqrt()
822 };
823
824 let (mu, y) = greens::mu_y_from_heating(&dq_dz, 1e2, z_h * 5.0, 5000);
825 let delta_n = greens::distortion_from_heating(&x_grid, &dq_dz, 1e2, z_h * 5.0, 5000);
826
827 Ok(GreensResult {
828 z_h,
829 mu,
830 y,
831 x_grid,
832 delta_n,
833 warnings: Vec::new(),
834 })
835}
836
837fn build_grid_config(n_grid: usize, production_grid: bool) -> GridConfig {
842 if production_grid {
843 if n_grid > 0 {
844 GridConfig {
845 n_points: n_grid,
846 ..GridConfig::production()
847 }
848 } else {
849 GridConfig::production()
850 }
851 } else if n_grid > 0 {
852 GridConfig {
853 n_points: n_grid,
854 ..GridConfig::default()
855 }
856 } else {
857 GridConfig::default()
858 }
859}
860
861fn build_solver_config(solver_opts: &SolverOpts, z_start: f64, z_end: f64) -> SolverConfig {
863 let effective_dy = solver_opts.dy_max.unwrap_or(SolverConfig::default().dy_max);
864 let effective_dtau = solver_opts.dtau_max.unwrap_or(10.0);
865 let defaults = SolverConfig::default();
866 SolverConfig {
867 z_start,
868 z_end,
869 dy_max: effective_dy,
870 dtau_max: effective_dtau,
871 nc_z_min: solver_opts.nc_z_min.unwrap_or(5.0e4),
872 dtau_max_photon_source: solver_opts
873 .dtau_max_photon_source
874 .unwrap_or(defaults.dtau_max_photon_source),
875 ..defaults
876 }
877}
878
879fn extract_panic_message(payload: &(dyn std::any::Any + Send)) -> String {
881 payload
882 .downcast_ref::<String>()
883 .map(|s| s.as_str())
884 .or_else(|| payload.downcast_ref::<&str>().copied())
885 .unwrap_or("unknown error")
886 .to_string()
887}
888
889fn default_photon_sweep_redshifts() -> Vec<f64> {
891 let n = 150;
892 (0..n)
893 .map(|i| {
894 let t = i as f64 / (n - 1) as f64;
895 let log_max = (5e6_f64).log10();
896 10f64.powf(3.0 + t * (log_max - 3.0))
897 })
898 .collect()
899}
900
901fn dedup_keep_order(items: Vec<String>) -> Vec<String> {
906 use std::collections::HashSet;
907 let mut seen: HashSet<String> = HashSet::new();
908 let mut out = Vec::with_capacity(items.len());
909 for item in items {
910 if seen.insert(item.clone()) {
911 out.push(item);
912 }
913 }
914 out
915}
916
917fn diagnostic_flag_warnings(solver_opts: &SolverOpts) -> Vec<String> {
921 let mut out = Vec::new();
922 if solver_opts.disable_dcbr {
923 out.push(
924 "--no-dcbr disables double-Compton and bremsstrahlung. Photon number is no longer \
925 adjusted downward, so μ and y lose physical meaning once Δρ/ρ grows beyond the \
926 linear regime. Diagnostic flag — not for production runs."
927 .to_string(),
928 );
929 }
930 if solver_opts.split_dcbr {
931 out.push(
932 "--split-dcbr disables the coupled DC/BR Newton iteration. Photon-source spikes can \
933 produce timestep-dependent wakes at intermediate x. Diagnostic flag — prefer the \
934 coupled mode for production runs."
935 .to_string(),
936 );
937 }
938 if solver_opts.cn_dcbr {
939 out.push(
940 "--cn-dcbr selects Crank-Nicolson for DC/BR. The CN scheme can produce negative \
941 diagonals at low x where DC/BR rates diverge (CLAUDE.md pitfall #3); the default \
942 backward-Euler is the validated path. Diagnostic flag only."
943 .to_string(),
944 );
945 }
946 out
947}
948
949fn apply_solver_flags(solver: &mut ThermalizationSolver, solver_opts: &SolverOpts) {
951 solver.disable_dcbr = solver_opts.disable_dcbr;
952 solver.number_conserving = solver_opts.number_conserving;
953 for w in diagnostic_flag_warnings(solver_opts) {
954 eprintln!(" Warning: {w}");
955 solver.diag.warnings.push(w);
956 }
957 if let Some(stride) = solver_opts.nc_stride {
958 solver.nc_stride = stride;
959 }
960 if solver_opts.split_dcbr {
961 solver.coupled_dcbr = false;
962 }
963 if solver_opts.cn_dcbr {
964 solver.config.cn_dcbr = true;
965 }
966}
967
968fn validate_and_collect_warnings(
976 config: &SolverConfig,
977 grid_config: &GridConfig,
978 injection: &InjectionScenario,
979 cosmo: &Cosmology,
980) -> Result<Vec<String>, String> {
981 cosmo.validate()?;
982 grid_config.validate()?;
983 config.validate()?;
984 injection.validate()?;
985
986 if let Some((_z_center, z_upper)) = injection.characteristic_redshift() {
987 if config.z_start < z_upper {
988 return Err(format!(
989 "z_start={:.3e} is below the injection window upper bound z={:.3e}. \
990 The solver would miss part or all of the injection. Set z_start >= {:.3e}.",
991 config.z_start, z_upper, z_upper
992 ));
993 }
994 if config.z_end > z_upper {
998 return Err(format!(
999 "z_end={:.3e} is above the injection window upper bound z={:.3e}. \
1000 The solver would terminate before the injection epoch is reached. \
1001 Set z_end < {:.3e}.",
1002 config.z_end, z_upper, z_upper
1003 ));
1004 }
1005 }
1006
1007 let mut warnings = config.soft_warnings();
1011 warnings.extend(injection.warn_strong_distortion());
1012 warnings.extend(injection.warn_tabulated_coverage(config.z_start, config.z_end));
1013 warnings.extend(injection.warn_dark_photon_range(cosmo));
1014 Ok(warnings)
1015}
1016
1017pub fn execute_solve(opts: &SolveOpts) -> Result<SolverResult, String> {
1019 let cosmo = build_cosmology(&opts.cosmo)?;
1020 let delta_rho: f64 = opts
1021 .params
1022 .get("--delta-rho")
1023 .map(|s| s.parse().map_err(|_| "Invalid --delta-rho".to_string()))
1024 .transpose()?
1025 .unwrap_or(1e-5);
1026 if !delta_rho.is_finite() {
1027 return Err(format!("--delta-rho must be finite, got {delta_rho}"));
1028 }
1029
1030 let injection = build_injection_scenario(&opts.injection_type, &opts.params, delta_rho)?;
1031 injection.validate()?;
1032 eprintln!("Injection: {}", opts.injection_type);
1033
1034 let n_grid = opts.solver.n_points.unwrap_or(2000);
1035 let effective_dy_max = opts.solver.dy_max.unwrap_or(SolverConfig::default().dy_max);
1036 let effective_dtau_max = opts.solver.dtau_max.unwrap_or(10.0);
1037 let default_z_start = match injection.dark_photon_params(&cosmo) {
1041 Some((_, z_res)) => z_res,
1042 None => 5e6,
1043 };
1044 let z_start = opts.solver.z_start.unwrap_or(default_z_start);
1045 let z_end = opts.solver.z_end;
1046
1047 if matches!(injection, InjectionScenario::DarkPhotonResonance { .. })
1051 && injection.dark_photon_params(&cosmo).is_none()
1052 {
1053 return Err(
1054 "DarkPhotonResonance: no resonance redshift z_res in [50, 3e6] for the given \
1055 (epsilon, m_ev). The plasma frequency never crosses the dark-photon mass in the \
1056 supported band, so no conversion occurs. Adjust m_ev or extend the supported range."
1057 .to_string(),
1058 );
1059 }
1060
1061 let mut grid_config = build_grid_config(n_grid, opts.solver.production_grid);
1062
1063 if !opts.solver.no_auto_refine {
1064 for zone in injection.refinement_zones() {
1065 grid_config.refinement_zones.push(zone);
1066 }
1067 if let Some(x_min) = injection.suggested_x_min() {
1068 if x_min < grid_config.x_min {
1069 grid_config.x_min = x_min;
1070 }
1071 }
1072 }
1073
1074 let defaults_for_validate = SolverConfig::default();
1075 let probe_config = SolverConfig {
1076 z_start,
1077 z_end,
1078 dy_max: effective_dy_max,
1079 dtau_max: effective_dtau_max,
1080 nc_z_min: opts.solver.nc_z_min.unwrap_or(5.0e4),
1081 dtau_max_photon_source: opts
1082 .solver
1083 .dtau_max_photon_source
1084 .unwrap_or(defaults_for_validate.dtau_max_photon_source),
1085 ..defaults_for_validate
1086 };
1087 let preflight_warnings =
1088 validate_and_collect_warnings(&probe_config, &grid_config, &injection, &cosmo)?;
1089
1090 let mut solver = ThermalizationSolver::new(cosmo, grid_config);
1091 apply_solver_flags(&mut solver, &opts.solver);
1092 solver.set_injection(injection)?;
1093 solver.diag.warnings.extend(preflight_warnings);
1094
1095 if let Some(amp) = opts.solver.dn_planck {
1096 if !amp.is_finite() {
1097 return Err(format!("--dn-planck amplitude must be finite, got {amp}"));
1098 }
1099 let initial_dn: Vec<f64> = solver
1100 .grid
1101 .x
1102 .iter()
1103 .map(|&x| amp * crate::spectrum::planck(x))
1104 .collect();
1105 solver.set_initial_delta_n(initial_dn);
1106 }
1107
1108 let defaults = SolverConfig::default();
1111 solver.set_config(SolverConfig {
1112 z_start,
1113 z_end,
1114 dy_max: effective_dy_max,
1115 dtau_max: effective_dtau_max,
1116 nc_z_min: opts.solver.nc_z_min.unwrap_or(5.0e4),
1117 dtau_max_photon_source: opts
1118 .solver
1119 .dtau_max_photon_source
1120 .unwrap_or(defaults.dtau_max_photon_source),
1121 ..defaults
1122 });
1123
1124 let result = solver.run_to_result(z_end);
1125 let last = &result.snapshot;
1126 eprintln!(
1127 "PDE result: mu={:.4e}, y={:.4e}, drho={:.4e}, steps={}",
1128 last.mu, last.y, last.delta_rho_over_rho, result.step_count
1129 );
1130
1131 Ok(result)
1132}
1133
1134pub fn execute_sweep(opts: &SweepOpts) -> Result<SweepResult, String> {
1136 let cosmo = build_cosmology(&opts.cosmo)?;
1137 let delta_rho = opts.delta_rho;
1138 if !delta_rho.is_finite() {
1139 return Err(format!("--delta-rho must be finite, got {delta_rho}"));
1140 }
1141 let z_end = opts.solver.z_end;
1142 let n_grid = opts.solver.n_points.unwrap_or(0);
1143
1144 let injection_redshifts: Vec<f64> = opts.z_injections.clone().unwrap_or_else(|| {
1145 vec![
1146 2e3, 3e3, 5e3, 7e3, 1e4, 1.5e4, 2e4, 3e4, 5e4, 7e4, 1e5, 1.5e5, 2e5, 3e5, 5e5, 1e6, 3e6,
1147 ]
1148 });
1149
1150 let n_threads = opts.solver.n_threads.unwrap_or_else(|| {
1151 std::thread::available_parallelism()
1152 .map(|n| n.get())
1153 .unwrap_or(4)
1154 });
1155 let mut rows_all: Vec<SweepRow> = Vec::with_capacity(injection_redshifts.len());
1156 let mut warnings_all: Vec<String> = Vec::new();
1157 for chunk in injection_redshifts.chunks(n_threads) {
1158 let chunk_rows: Result<Vec<(SweepRow, Vec<String>)>, String> = std::thread::scope(|s| {
1159 let handles: Vec<_> = chunk
1160 .iter()
1161 .map(|&z_h| {
1162 let cosmo = cosmo.clone();
1163 let solver_opts = &opts.solver;
1164 s.spawn(move || -> Result<(SweepRow, Vec<String>), String> {
1165 let sigma: f64 = (z_h * 0.04_f64).max(100.0);
1166 let z_start: f64 = solver_opts.z_start.unwrap_or(z_h + 7.0 * sigma);
1167
1168 let grid_config = build_grid_config(n_grid, solver_opts.production_grid);
1169 let injection = InjectionScenario::SingleBurst {
1170 z_h,
1171 delta_rho_over_rho: delta_rho,
1172 sigma_z: sigma,
1173 };
1174 let probe_config = build_solver_config(solver_opts, z_start, z_end);
1175 let preflight = validate_and_collect_warnings(
1176 &probe_config,
1177 &grid_config,
1178 &injection,
1179 &cosmo,
1180 )?;
1181
1182 let mut solver = ThermalizationSolver::new(cosmo, grid_config);
1183 apply_solver_flags(&mut solver, solver_opts);
1184 solver.set_injection(injection)?;
1185 solver.set_config(probe_config);
1186 solver.diag.warnings.extend(preflight);
1187
1188 solver.run_with_snapshots(&[z_end]);
1189 let step_count = solver.step_count;
1190 let x_grid = solver.grid.x.clone();
1191 let row_warnings = solver.diag.warnings.clone();
1192 let snapshot = solver
1193 .snapshots
1194 .last()
1195 .ok_or_else(|| {
1196 format!("sweep z_h={z_h:.3e}: solver produced no snapshots")
1197 })?
1198 .clone();
1199
1200 let gf_z_min = (z_h - 10.0 * sigma).max(1e2);
1201 let gf_z_max = z_h + 10.0 * sigma;
1202 let dq_dz = |z: f64| -> f64 {
1203 delta_rho * (-(z - z_h).powi(2) / (2.0 * sigma * sigma)).exp()
1204 / (2.0 * std::f64::consts::PI * sigma * sigma).sqrt()
1205 };
1206 let (gf_mu, gf_y) =
1207 greens::mu_y_from_heating(&dq_dz, gf_z_min, gf_z_max, 5000);
1208 let gf_delta_n = greens::distortion_from_heating(
1209 &x_grid, &dq_dz, gf_z_min, gf_z_max, 5000,
1210 );
1211
1212 Ok((
1213 SweepRow {
1214 z_h,
1215 snapshot,
1216 gf_mu,
1217 gf_y,
1218 gf_delta_n,
1219 x_grid,
1220 step_count,
1221 },
1222 row_warnings,
1223 ))
1224 })
1225 })
1226 .collect();
1227 let mut results = Vec::with_capacity(handles.len());
1228 for h in handles {
1229 match h.join() {
1230 Ok(Ok(row)) => results.push(row),
1231 Ok(Err(msg)) => return Err(format!("Sweep worker error: {msg}")),
1232 Err(e) => {
1233 return Err(format!(
1234 "Sweep thread panicked: {}",
1235 extract_panic_message(&e)
1236 ));
1237 }
1238 }
1239 }
1240 Ok(results)
1241 });
1242 for (row, ws) in chunk_rows? {
1243 rows_all.push(row);
1244 warnings_all.extend(ws);
1245 }
1246 }
1247 let rows = rows_all;
1248
1249 Ok(SweepResult {
1250 delta_rho,
1251 rows,
1252 warnings: dedup_keep_order(warnings_all),
1253 })
1254}
1255
1256pub fn execute_photon_sweep(opts: &PhotonSweepOpts) -> Result<PhotonSweepResult, String> {
1259 let cosmo = build_cosmology(&opts.cosmo)?;
1260 let x_inj = opts.x_inj;
1261 let delta_n_over_n = opts.delta_n_over_n;
1262 if !x_inj.is_finite() || x_inj <= 0.0 {
1263 return Err(format!("--x-inj must be positive and finite, got {x_inj}"));
1264 }
1265 if !delta_n_over_n.is_finite() {
1266 return Err(format!(
1267 "--delta-n-over-n must be finite, got {delta_n_over_n}"
1268 ));
1269 }
1270 let sigma_x = opts.sigma_x.unwrap_or(x_inj * 0.05);
1271 let z_end = opts.solver.z_end;
1272 let n_grid = opts.solver.n_points.unwrap_or(2000);
1273
1274 let injection_redshifts: Vec<f64> = opts
1275 .z_injections
1276 .clone()
1277 .unwrap_or_else(default_photon_sweep_redshifts);
1278
1279 eprintln!(
1280 "Photon sweep: x_inj={x_inj:.4e}, ΔN/N={delta_n_over_n:.4e}, {} redshifts",
1281 injection_redshifts.len()
1282 );
1283
1284 let n_threads = opts.solver.n_threads.unwrap_or_else(|| {
1285 std::thread::available_parallelism()
1286 .map(|n| n.get())
1287 .unwrap_or(4)
1288 });
1289 let mut rows_all: Vec<PhotonSweepRow> = Vec::with_capacity(injection_redshifts.len());
1290 let mut warnings_all: Vec<String> = Vec::new();
1291 for chunk in injection_redshifts.chunks(n_threads) {
1292 let chunk_rows: Result<Vec<(PhotonSweepRow, Vec<String>)>, String> =
1293 std::thread::scope(|s| {
1294 let handles: Vec<_> = chunk
1295 .iter()
1296 .map(|&z_h| {
1297 let cosmo = cosmo.clone();
1298 let solver_opts = &opts.solver;
1299 s.spawn(move || -> Result<(PhotonSweepRow, Vec<String>), String> {
1300 let sigma_z: f64 = (z_h * 0.04_f64).max(100.0);
1301 let z_start_val: f64 =
1302 solver_opts.z_start.unwrap_or(z_h + 7.0 * sigma_z);
1303
1304 let mut grid_config =
1305 build_grid_config(n_grid, solver_opts.production_grid);
1306
1307 let injection = InjectionScenario::MonochromaticPhotonInjection {
1308 x_inj,
1309 delta_n_over_n,
1310 z_h,
1311 sigma_z,
1312 sigma_x,
1313 };
1314
1315 if !solver_opts.no_auto_refine {
1316 for zone in injection.refinement_zones() {
1317 grid_config.refinement_zones.push(zone);
1318 }
1319 if let Some(x_min) = injection.suggested_x_min() {
1320 if x_min < grid_config.x_min {
1321 grid_config.x_min = x_min;
1322 }
1323 }
1324 }
1325
1326 let probe_config = build_solver_config(solver_opts, z_start_val, z_end);
1327 let preflight = validate_and_collect_warnings(
1328 &probe_config,
1329 &grid_config,
1330 &injection,
1331 &cosmo,
1332 )?;
1333
1334 let mut solver = ThermalizationSolver::new(cosmo, grid_config);
1335 apply_solver_flags(&mut solver, solver_opts);
1336 solver.set_injection(injection)?;
1337 solver.set_config(probe_config);
1338 solver.diag.warnings.extend(preflight);
1339
1340 solver.run_with_snapshots(&[z_end]);
1341 let step_count = solver.step_count;
1342 let x_grid = solver.grid.x.clone();
1343 let row_warnings = solver.diag.warnings.clone();
1344 let snapshot = solver
1345 .snapshots
1346 .last()
1347 .ok_or_else(|| {
1348 format!("photon sweep z_h={z_h:.3e}: no snapshots produced")
1349 })?
1350 .clone();
1351
1352 Ok((
1353 PhotonSweepRow {
1354 z_h,
1355 snapshot,
1356 x_grid,
1357 step_count,
1358 },
1359 row_warnings,
1360 ))
1361 })
1362 })
1363 .collect();
1364 let mut results = Vec::with_capacity(handles.len());
1365 for h in handles {
1366 match h.join() {
1367 Ok(Ok(row)) => results.push(row),
1368 Ok(Err(msg)) => {
1369 return Err(format!("Photon sweep worker error: {msg}"));
1370 }
1371 Err(e) => {
1372 return Err(format!(
1373 "Photon sweep thread panicked: {}",
1374 extract_panic_message(&e)
1375 ));
1376 }
1377 }
1378 }
1379 Ok(results)
1380 });
1381 for (row, ws) in chunk_rows? {
1382 rows_all.push(row);
1383 warnings_all.extend(ws);
1384 }
1385 }
1386 let rows = rows_all;
1387
1388 Ok(PhotonSweepResult {
1389 x_inj,
1390 delta_n_over_n,
1391 rows,
1392 warnings: dedup_keep_order(warnings_all),
1393 })
1394}
1395
1396pub fn execute_photon_sweep_batch(
1401 opts: &PhotonSweepBatchOpts,
1402) -> Result<PhotonSweepBatchResult, String> {
1403 let cosmo = build_cosmology(&opts.cosmo)?;
1404 let delta_n_over_n = opts.delta_n_over_n;
1405 let z_end = opts.solver.z_end;
1406 let n_grid = opts.solver.n_points.unwrap_or(2000);
1407
1408 let injection_redshifts: Vec<f64> = opts
1409 .z_injections
1410 .clone()
1411 .unwrap_or_else(default_photon_sweep_redshifts);
1412
1413 let n_xinj = opts.x_inj_values.len();
1414 let n_zh = injection_redshifts.len();
1415 let total = n_xinj * n_zh;
1416
1417 eprintln!(
1418 "Photon sweep batch: {} x_inj × {} z_h = {} PDE runs",
1419 n_xinj, n_zh, total
1420 );
1421
1422 let mut tasks: Vec<(usize, usize)> = Vec::with_capacity(total);
1424 for xi_idx in 0..n_xinj {
1425 for zh_idx in 0..n_zh {
1426 tasks.push((xi_idx, zh_idx));
1427 }
1428 }
1429
1430 let n_threads = opts.solver.n_threads.unwrap_or_else(|| {
1432 std::thread::available_parallelism()
1433 .map(|n| n.get())
1434 .unwrap_or(4)
1435 });
1436 let mut rows_all: Vec<(usize, usize, PhotonSweepRow, Vec<String>)> =
1437 Vec::with_capacity(tasks.len());
1438 for task_chunk in tasks.chunks(n_threads) {
1439 let chunk_rows: Result<Vec<(usize, usize, PhotonSweepRow, Vec<String>)>, String> =
1440 std::thread::scope(|s| {
1441 let handles: Vec<_> = task_chunk
1442 .iter()
1443 .map(|&(xi_idx, zh_idx)| {
1444 let cosmo = cosmo.clone();
1445 let solver_opts = &opts.solver;
1446 let x_inj = opts.x_inj_values[xi_idx];
1447 let sigma_x = opts.sigma_x.unwrap_or(x_inj * 0.05);
1448 let z_h = injection_redshifts[zh_idx];
1449 s.spawn(move || -> Result<(usize, usize, PhotonSweepRow, Vec<String>), String> {
1450 let sigma_z: f64 = (z_h * 0.04_f64).max(100.0);
1451 let z_start_val: f64 =
1452 solver_opts.z_start.unwrap_or(z_h + 7.0 * sigma_z);
1453
1454 let mut grid_config =
1455 build_grid_config(n_grid, solver_opts.production_grid);
1456
1457 let injection = InjectionScenario::MonochromaticPhotonInjection {
1458 x_inj,
1459 delta_n_over_n,
1460 z_h,
1461 sigma_z,
1462 sigma_x,
1463 };
1464
1465 if !solver_opts.no_auto_refine {
1466 for zone in injection.refinement_zones() {
1467 grid_config.refinement_zones.push(zone);
1468 }
1469 if let Some(x_min) = injection.suggested_x_min() {
1470 if x_min < grid_config.x_min {
1471 grid_config.x_min = x_min;
1472 }
1473 }
1474 }
1475
1476 let probe_config =
1477 build_solver_config(solver_opts, z_start_val, z_end);
1478 let preflight = validate_and_collect_warnings(
1479 &probe_config,
1480 &grid_config,
1481 &injection,
1482 &cosmo,
1483 )?;
1484
1485 let mut solver = ThermalizationSolver::new(cosmo, grid_config);
1486 apply_solver_flags(&mut solver, solver_opts);
1487 solver.set_injection(injection)?;
1488 solver.set_config(probe_config);
1489 solver.diag.warnings.extend(preflight);
1490
1491 solver.run_with_snapshots(&[z_end]);
1492 let step_count = solver.step_count;
1493 let x_grid = solver.grid.x.clone();
1494 let row_warnings = solver.diag.warnings.clone();
1495 let snapshot = solver
1496 .snapshots
1497 .last()
1498 .ok_or_else(|| {
1499 format!(
1500 "photon sweep batch (x_inj={x_inj:.3e}, z_h={z_h:.3e}): \
1501 no snapshots produced"
1502 )
1503 })?
1504 .clone();
1505
1506 Ok((
1507 xi_idx,
1508 zh_idx,
1509 PhotonSweepRow {
1510 z_h,
1511 snapshot,
1512 x_grid,
1513 step_count,
1514 },
1515 row_warnings,
1516 ))
1517 })
1518 })
1519 .collect();
1520
1521 let mut results = Vec::with_capacity(handles.len());
1522 for h in handles {
1523 match h.join() {
1524 Ok(Ok(row)) => results.push(row),
1525 Ok(Err(msg)) => {
1526 return Err(format!("Photon sweep batch worker error: {msg}"));
1527 }
1528 Err(e) => {
1529 return Err(format!(
1530 "Photon sweep batch thread panicked: {}",
1531 extract_panic_message(&e)
1532 ));
1533 }
1534 }
1535 }
1536 Ok(results)
1537 });
1538 rows_all.extend(chunk_rows?);
1539 }
1540 let rows = rows_all;
1541
1542 let mut per_xinj: Vec<Vec<PhotonSweepRow>> =
1544 (0..n_xinj).map(|_| Vec::with_capacity(n_zh)).collect();
1545 let mut per_xinj_warnings: Vec<Vec<String>> = (0..n_xinj).map(|_| Vec::new()).collect();
1546 for (xi_idx, _zh_idx, row, ws) in rows {
1547 per_xinj[xi_idx].push(row);
1548 per_xinj_warnings[xi_idx].extend(ws);
1549 }
1550
1551 for group in &mut per_xinj {
1553 group.sort_by(|a, b| a.z_h.total_cmp(&b.z_h));
1554 }
1555
1556 let results: Vec<PhotonSweepResult> = per_xinj
1557 .into_iter()
1558 .zip(per_xinj_warnings)
1559 .enumerate()
1560 .map(|(i, (rows, ws))| PhotonSweepResult {
1561 x_inj: opts.x_inj_values[i],
1562 delta_n_over_n,
1563 rows,
1564 warnings: dedup_keep_order(ws),
1565 })
1566 .collect();
1567
1568 let aggregated_warnings: Vec<String> = results
1569 .iter()
1570 .flat_map(|r| r.warnings.iter().cloned())
1571 .collect();
1572
1573 Ok(PhotonSweepBatchResult {
1574 results,
1575 warnings: dedup_keep_order(aggregated_warnings),
1576 })
1577}
1578
1579#[cfg(test)]
1580mod tests {
1581 use super::*;
1582
1583 fn make_args(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1584 pairs
1585 .iter()
1586 .map(|(k, v)| (k.to_string(), v.to_string()))
1587 .collect()
1588 }
1589
1590 fn s(val: &str) -> String {
1591 val.into()
1592 }
1593
1594 #[test]
1597 fn test_parse_commands() {
1598 match parse_command(&[s("help")]).unwrap() {
1600 Command::Help => {}
1601 _ => panic!("Expected Help"),
1602 }
1603
1604 match parse_command(&[]).unwrap() {
1606 Command::Help => {}
1607 _ => panic!("Expected Help for empty args"),
1608 }
1609
1610 let args = vec![
1612 s("solve"),
1613 s("single-burst"),
1614 s("--z-h"),
1615 s("2e5"),
1616 s("--delta-rho"),
1617 s("1e-5"),
1618 ];
1619 match parse_command(&args).unwrap() {
1620 Command::Solve(opts) => {
1621 assert_eq!(opts.injection_type, "single-burst");
1622 assert_eq!(opts.params.get("--z-h").unwrap(), "2e5");
1623 }
1624 _ => panic!("Expected Solve"),
1625 }
1626
1627 let args = vec![
1629 s("sweep"),
1630 s("--z-injections"),
1631 s("1e3,1e4"),
1632 s("--format"),
1633 s("csv"),
1634 ];
1635 match parse_command(&args).unwrap() {
1636 Command::Sweep(opts) => {
1637 assert_eq!(opts.z_injections.as_ref().unwrap().len(), 2);
1638 assert_eq!(opts.output.format, OutputFormat::Csv);
1639 }
1640 _ => panic!("Expected Sweep"),
1641 }
1642
1643 let args = vec![s("greens"), s("--z-h"), s("2e5")];
1645 match parse_command(&args).unwrap() {
1646 Command::Greens(opts) => assert!((opts.z_h - 2e5).abs() < 1.0),
1647 _ => panic!("Expected Greens"),
1648 }
1649
1650 let args = vec![s("info"), s("--cosmology"), s("planck2018")];
1652 match parse_command(&args).unwrap() {
1653 Command::Info(opts) => assert_eq!(opts.cosmology, "planck2018"),
1654 _ => panic!("Expected Info"),
1655 }
1656
1657 match parse_command(&[s("info")]).unwrap() {
1659 Command::Info(opts) => assert_eq!(opts.cosmology, "default"),
1660 _ => panic!("Expected Info"),
1661 }
1662
1663 let args = vec![s("--injection"), s("single-burst"), s("--z-h"), s("2e5")];
1665 assert!(parse_command(&args).is_err());
1666 }
1667
1668 #[test]
1669 fn test_parse_errors() {
1670 let err = parse_command(&[s("foobar")]).unwrap_err();
1672 assert!(err.contains("Unknown subcommand") && err.contains("foobar"));
1673
1674 let err = parse_command(&[s("solve")]).unwrap_err();
1676 assert!(err.contains("injection type"));
1677
1678 let err = parse_command(&[s("solve"), s("--z-h"), s("2e5")]).unwrap_err();
1680 assert!(err.contains("--z-h"));
1681
1682 let err = parse_command(&[s("greens")]).unwrap_err();
1684 assert!(err.contains("--z-h"));
1685
1686 let err = parse_command(&[s("greens"), s("--z-h"), s("abc")]).unwrap_err();
1688 assert!(err.contains("Invalid"));
1689
1690 let err = parse_command(&[s("sweep"), s("--format"), s("xml")]).unwrap_err();
1692 assert!(err.contains("Unknown"));
1693
1694 assert!(parse_command(&[s("sweep"), s("--dy-max"), s("not_a_number")]).is_err());
1696 }
1697
1698 #[test]
1699 fn test_parse_float_list() {
1700 let result = parse_float_list("1e3, 1e4, 1e5").unwrap();
1701 assert_eq!(result.len(), 3);
1702 assert!((result[0] - 1e3).abs() < 1.0);
1703
1704 let err = parse_float_list("1e3,abc,1e5").unwrap_err();
1705 assert!(err.contains("Invalid number"));
1706 }
1707
1708 #[test]
1709 fn test_solver_opts_all_flags() {
1710 let args: Vec<String> = vec![
1711 s("sweep"),
1712 s("--dy-max"),
1713 s("0.01"),
1714 s("--dtau-max"),
1715 s("0.5"),
1716 s("--n-points"),
1717 s("2000"),
1718 s("--z-start"),
1719 s("1e6"),
1720 s("--z-end"),
1721 s("500"),
1722 s("--no-dcbr"),
1723 s("--split-dcbr"),
1724 s("--nc-z-min"),
1725 s("5e4"),
1726 s("--production-grid"),
1727 s("--dn-planck"),
1728 s("1e-4"),
1729 s("--no-auto-refine"),
1730 ];
1731 match parse_command(&args).unwrap() {
1732 Command::Sweep(opts) => {
1733 assert!(opts.solver.disable_dcbr);
1734 assert!(opts.solver.split_dcbr);
1735 assert!(opts.solver.number_conserving);
1736 assert!(opts.solver.production_grid);
1737 assert!(opts.solver.no_auto_refine);
1738 assert!((opts.solver.dy_max.unwrap() - 0.01).abs() < 1e-10);
1739 assert!((opts.solver.nc_z_min.unwrap() - 5e4).abs() < 1.0);
1740 assert!((opts.solver.dn_planck.unwrap() - 1e-4).abs() < 1e-15);
1741 }
1742 _ => panic!("Expected Sweep"),
1743 }
1744 }
1745
1746 #[test]
1749 fn test_build_cosmology_variants() {
1750 assert!((build_cosmology(&CosmoOpts::default()).unwrap().h - 0.71).abs() < 1e-10);
1752 assert!(
1753 (build_cosmology(&CosmoOpts {
1754 preset: Some("planck2018".into()),
1755 ..CosmoOpts::default()
1756 })
1757 .unwrap()
1758 .h - 0.6736)
1759 .abs()
1760 < 1e-10
1761 );
1762 assert!(
1763 (build_cosmology(&CosmoOpts {
1764 preset: Some("planck2015".into()),
1765 ..CosmoOpts::default()
1766 })
1767 .unwrap()
1768 .h - 0.6727)
1769 .abs()
1770 < 1e-10
1771 );
1772 let result = build_cosmology(&CosmoOpts {
1774 preset: Some("unknown".into()),
1775 ..CosmoOpts::default()
1776 });
1777 assert!(result.is_err(), "Unknown preset should return Err");
1778
1779 let custom = build_cosmology(&CosmoOpts {
1781 h: Some(0.70),
1782 omega_b: Some(0.04),
1783 omega_m: Some(0.30),
1784 t_cmb: Some(2.73),
1785 ..CosmoOpts::default()
1786 })
1787 .unwrap();
1788 assert!((custom.h - 0.70).abs() < 1e-10);
1789 assert!((custom.omega_b - 0.04 * 0.70 * 0.70).abs() < 1e-10);
1791 assert!((custom.omega_cdm - 0.26 * 0.70 * 0.70).abs() < 1e-10);
1793 assert!((custom.t_cmb - 2.73).abs() < 1e-10);
1794
1795 assert!(
1799 build_cosmology(&CosmoOpts {
1800 omega_b: Some(0.04),
1801 ..CosmoOpts::default()
1802 })
1803 .is_err()
1804 );
1805
1806 let args: Vec<String> = vec![
1808 s("sweep"),
1809 s("--omega-b"),
1810 s("0.05"),
1811 s("--omega-m"),
1812 s("0.31"),
1813 s("--h"),
1814 s("0.67"),
1815 s("--n-eff"),
1816 s("3.044"),
1817 s("--y-p"),
1818 s("0.245"),
1819 s("--t-cmb"),
1820 s("2.7255"),
1821 ];
1822 match parse_command(&args).unwrap() {
1823 Command::Sweep(opts) => {
1824 assert!((opts.cosmo.omega_b.unwrap() - 0.05).abs() < 1e-10);
1825 assert!((opts.cosmo.omega_m.unwrap() - 0.31).abs() < 1e-10);
1826 assert!((opts.cosmo.h.unwrap() - 0.67).abs() < 1e-10);
1827 assert!((opts.cosmo.t_cmb.unwrap() - 2.7255).abs() < 1e-10);
1828 }
1829 _ => panic!("Expected Sweep"),
1830 }
1831
1832 let stale: Vec<String> = vec![s("sweep"), s("--omega-cdm"), s("0.12"), s("--h"), s("0.67")];
1834 let err = parse_command(&stale).unwrap_err();
1835 assert!(
1836 err.contains("--omega-cdm is no longer accepted"),
1837 "expected migration message, got: {err}"
1838 );
1839 }
1840
1841 #[test]
1844 fn test_build_injection_all_types() {
1845 let cases: Vec<(&str, Vec<(&str, &str)>, fn(&InjectionScenario) -> bool)> = vec![
1847 ("single-burst", vec![("--z-h", "2e5")], |i| {
1848 matches!(i, InjectionScenario::SingleBurst { .. })
1849 }),
1850 (
1851 "decaying-particle",
1852 vec![("--f-x", "1e-8"), ("--gamma-x", "1e4")],
1853 |i| matches!(i, InjectionScenario::DecayingParticle { .. }),
1854 ),
1855 ("annihilating-dm", vec![("--f-ann", "1e-23")], |i| {
1856 matches!(i, InjectionScenario::AnnihilatingDM { .. })
1857 }),
1858 ("annihilating-dm-pwave", vec![("--f-ann", "1e-23")], |i| {
1859 matches!(i, InjectionScenario::AnnihilatingDMPWave { .. })
1860 }),
1861 (
1862 "monochromatic-photon",
1863 vec![
1864 ("--x-inj", "3.0"),
1865 ("--delta-n-over-n", "1e-6"),
1866 ("--z-h", "1e5"),
1867 ],
1868 |i| matches!(i, InjectionScenario::MonochromaticPhotonInjection { .. }),
1869 ),
1870 (
1871 "decaying-particle-photon",
1872 vec![
1873 ("--x-inj-0", "3.0"),
1874 ("--f-inj", "1e-6"),
1875 ("--gamma-x", "1e4"),
1876 ],
1877 |i| matches!(i, InjectionScenario::DecayingParticlePhoton { .. }),
1878 ),
1879 ];
1880
1881 for (type_name, arg_pairs, check) in &cases {
1882 let args = make_args(arg_pairs);
1883 let inj = build_injection_scenario(type_name, &args, 1e-5)
1884 .unwrap_or_else(|e| panic!("Failed to build {type_name}: {e}"));
1885 assert!(check(&inj), "Wrong variant for {type_name}");
1886 }
1887
1888 let sb_args = make_args(&[("--z-h", "2e5")]);
1890 match build_injection_scenario("single-burst", &sb_args, 1e-5).unwrap() {
1891 InjectionScenario::SingleBurst {
1892 z_h,
1893 delta_rho_over_rho,
1894 ..
1895 } => {
1896 assert!((z_h - 2e5).abs() < 1.0);
1897 assert!((delta_rho_over_rho - 1e-5).abs() < 1e-14);
1898 }
1899 _ => unreachable!(),
1900 }
1901 }
1902
1903 #[test]
1904 fn test_build_injection_errors() {
1905 match build_injection_scenario("foobar", &HashMap::new(), 1e-5) {
1907 Err(e) => assert!(e.contains("Unknown injection type") && e.contains("foobar")),
1908 Ok(_) => panic!("Expected error for unknown type"),
1909 }
1910
1911 let args = make_args(&[("--f-ann", "not_a_number")]);
1913 assert!(build_injection_scenario("annihilating-dm", &args, 1e-5).is_err());
1914
1915 let args = make_args(&[("--sigma-z", "100.0")]);
1917 assert!(build_injection_scenario("single-burst", &args, f64::NAN).is_err());
1918 }
1919
1920 #[test]
1923 fn test_parse_flat_args_boolean_flag() {
1924 let args: Vec<String> = vec![s("--no-dcbr"), s("--z-h"), s("1e5")];
1925 let map = parse_flat_args(&args);
1926 assert!(map.contains_key("--no-dcbr"));
1927 assert_eq!(map["--no-dcbr"], "");
1928 assert_eq!(map["--z-h"], "1e5");
1929 }
1930}