spectroxide/
cli.rs

1//! CLI argument parsing with subcommands (zero dependencies).
2//!
3//! Supports both new-style subcommands and legacy flat-flag mode:
4//!
5//! ```text
6//! spectroxide solve single-burst --z-h 2e5 --delta-rho 1e-5
7//! spectroxide sweep --z-injections 1e3,1e4,1e5 --format csv
8//! spectroxide greens --z-h 2e5 --delta-rho 1e-5
9//! spectroxide info --cosmology planck2018
10//! spectroxide help
11//! ```
12//!
13//! If the first argument is not a known subcommand, falls back to legacy
14//! flat-flag parsing with a deprecation warning.
15
16use 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/// Top-level command parsed from CLI args.
26#[derive(Debug)]
27pub enum Command {
28    /// Run a single PDE solve with a specific injection scenario.
29    Solve(SolveOpts),
30    /// Run a sweep over multiple injection redshifts.
31    Sweep(SweepOpts),
32    /// Run a Green's function calculation (no PDE).
33    Greens(GreensOpts),
34    /// Print cosmology/solver info.
35    Info(InfoOpts),
36    /// Print the compile-time physics-source hash and exit.
37    PhysicsHash,
38    /// Sweep over injection redshifts for a single photon injection frequency.
39    PhotonSweep(PhotonSweepOpts),
40    /// Batch sweep over multiple x_inj values, parallelizing all (x_inj, z_h) pairs.
41    PhotonSweepBatch(PhotonSweepBatchOpts),
42    /// Print help text.
43    Help,
44}
45
46/// Options for `spectroxide solve <injection-type> [flags]`.
47#[derive(Debug)]
48pub struct SolveOpts {
49    /// Injection-scenario tag (positional, e.g. `single-burst`,
50    /// `decaying-particle`, `photon`). Drives which `--params` keys are
51    /// expected.
52    pub injection_type: String,
53    /// Free-form `key=value` parameters specific to the chosen injection
54    /// (parsed downstream into the corresponding `InjectionScenario` variant).
55    pub params: HashMap<String, String>,
56    /// Solver tuning knobs (timestepping, grid, DC/BR diagnostics).
57    pub solver: SolverOpts,
58    /// Cosmology overrides (preset and/or individual parameters).
59    pub cosmo: CosmoOpts,
60    /// Output format and destination.
61    pub output: OutputOpts,
62}
63
64/// Options for `spectroxide sweep [flags]`.
65#[derive(Debug)]
66pub struct SweepOpts {
67    /// Comma-separated injection redshifts from `--z-injections`.
68    /// `None` falls back to the built-in default grid.
69    pub z_injections: Option<Vec<f64>>,
70    /// Energy injection amplitude Δρ/ρ (`--delta-rho`).
71    pub delta_rho: f64,
72    /// Per-scenario `key=value` parameters (see [`SolveOpts::params`]).
73    pub params: HashMap<String, String>,
74    /// Solver tuning knobs.
75    pub solver: SolverOpts,
76    /// Cosmology overrides.
77    pub cosmo: CosmoOpts,
78    /// Output format and destination.
79    pub output: OutputOpts,
80}
81
82/// Options for `spectroxide greens [flags]`.
83#[derive(Debug)]
84pub struct GreensOpts {
85    /// Injection redshift `--z-h` (heating epoch).
86    pub z_h: f64,
87    /// Energy injection amplitude Δρ/ρ (`--delta-rho`).
88    pub delta_rho: f64,
89    /// Output format and destination.
90    pub output: OutputOpts,
91}
92
93/// Options for `spectroxide photon-sweep [flags]`.
94#[derive(Debug)]
95pub struct PhotonSweepOpts {
96    /// Injection frequency `--x-inj` in dimensionless units `x = hν/(kT_z)`.
97    pub x_inj: f64,
98    /// Photon-number injection amplitude `--delta-n-over-n`.
99    pub delta_n_over_n: f64,
100    /// Optional Gaussian width `--sigma-x` of the photon line in `x`.
101    /// `None` requests a delta-function injection.
102    pub sigma_x: Option<f64>,
103    /// Injection redshifts `--z-injections`. `None` uses the default sweep grid.
104    pub z_injections: Option<Vec<f64>>,
105    /// Solver tuning knobs.
106    pub solver: SolverOpts,
107    /// Cosmology overrides.
108    pub cosmo: CosmoOpts,
109    /// Output format and destination.
110    pub output: OutputOpts,
111}
112
113/// Options for `spectroxide photon-sweep-batch [flags]`.
114#[derive(Debug)]
115pub struct PhotonSweepBatchOpts {
116    /// Injection frequencies `--x-inj-values` (one sweep per value, run in
117    /// parallel across all `(x_inj, z_h)` pairs).
118    pub x_inj_values: Vec<f64>,
119    /// Photon-number injection amplitude `--delta-n-over-n`, shared across
120    /// all `x_inj` values.
121    pub delta_n_over_n: f64,
122    /// Optional Gaussian width `--sigma-x` of the injected line.
123    pub sigma_x: Option<f64>,
124    /// Injection redshifts `--z-injections`. `None` uses the default grid.
125    pub z_injections: Option<Vec<f64>>,
126    /// Solver tuning knobs.
127    pub solver: SolverOpts,
128    /// Cosmology overrides.
129    pub cosmo: CosmoOpts,
130    /// Output format and destination.
131    pub output: OutputOpts,
132}
133
134/// Options for `spectroxide info`.
135#[derive(Debug)]
136pub struct InfoOpts {
137    /// Named cosmology preset to display (`default`, `planck2015`,
138    /// `planck2018`).
139    pub cosmology: String,
140}
141
142/// Solver tuning parameters.
143#[derive(Debug)]
144pub struct SolverOpts {
145    /// Starting redshift `--z-start`. `None` lets each subcommand pick a default.
146    pub z_start: Option<f64>,
147    /// Ending redshift `--z-end` (default 500.0).
148    pub z_end: f64,
149    /// Cap on adaptive y-step `--dy-max`.
150    pub dy_max: Option<f64>,
151    /// Cap on adaptive optical-depth step `--dtau-max`.
152    pub dtau_max: Option<f64>,
153    /// Frequency-grid point count `--n-points`. Overrides the built-in
154    /// `fast` / `production` presets.
155    pub n_points: Option<usize>,
156    /// Disable double-Compton + bremsstrahlung emission/absorption (`--no-dcbr`).
157    pub disable_dcbr: bool,
158    /// Split the DC/BR step into separate DC and BR substeps (`--split-dcbr`).
159    pub split_dcbr: bool,
160    /// Use Crank-Nicolson (instead of backward Euler) for the DC/BR solve
161    /// (`--cn-dcbr`). Diagnostic only — known to fail at low x.
162    pub cn_dcbr: bool,
163    /// Keep the photon-number-conserving Kompaneets correction enabled
164    /// (default true; disabled with `--no-number-conserving`).
165    pub number_conserving: bool,
166    /// Apply the number-conserving correction every `nc_stride` steps
167    /// (`--nc-stride`).
168    pub nc_stride: Option<usize>,
169    /// Lower-z cutoff `--nc-z-min` below which the number-conserving
170    /// correction is suppressed.
171    pub nc_z_min: Option<f64>,
172    /// Use the high-resolution `production` grid preset (`--production-grid`).
173    pub production_grid: bool,
174    /// Override the initial-condition Δn at z_start (`--dn-planck`); used
175    /// for adiabatic / baseline diagnostics.
176    pub dn_planck: Option<f64>,
177    /// Disable automatic grid refinement for photon-injection scenarios
178    /// (`--no-auto-refine`).
179    pub no_auto_refine: bool,
180    /// Thread count for parallel sweep execution (`--threads`).
181    pub n_threads: Option<usize>,
182    /// Maximum dtau per step during photon-source injection
183    /// (`--dtau-max-photon-source`); tightens the timestep near a delta-line
184    /// source.
185    pub dtau_max_photon_source: Option<f64>,
186}
187
188/// Cosmology parameters.
189#[derive(Debug)]
190pub struct CosmoOpts {
191    /// Fractional baryon density `Ω_b` (`--omega-b`). Converted internally
192    /// to the physical density `ω_b = Ω_b h²` before the `Cosmology`
193    /// struct is built.
194    pub omega_b: Option<f64>,
195    /// Fractional total matter density `Ω_m = Ω_b + Ω_cdm` (`--omega-m`).
196    /// Combined with `omega_b` and `h` to compute the physical CDM density
197    /// `ω_cdm = (Ω_m − Ω_b) h²`.
198    pub omega_m: Option<f64>,
199    /// Reduced Hubble parameter `h = H₀ / (100 km/s/Mpc)` (`--h`).
200    pub h: Option<f64>,
201    /// Effective number of relativistic species `N_eff` (`--n-eff`).
202    pub n_eff: Option<f64>,
203    /// Helium mass fraction `Y_p` (`--y-p`).
204    pub y_p: Option<f64>,
205    /// CMB temperature today, in K (`--t-cmb`).
206    pub t_cmb: Option<f64>,
207    /// Named preset (`--cosmology`) such as `default`, `planck2015`,
208    /// `planck2018`. Individual flags above override preset values.
209    pub preset: Option<String>,
210}
211
212/// Output formatting options.
213#[derive(Debug)]
214pub struct OutputOpts {
215    /// Output format selected by `--format` (json, csv, table).
216    pub format: OutputFormat,
217    /// Optional file path for output (`--output`). If `None`, output goes
218    /// to stdout.
219    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
268/// Known subcommands.
269const SUBCOMMANDS: &[&str] = &[
270    "solve",
271    "sweep",
272    "photon-sweep",
273    "photon-sweep-batch",
274    "greens",
275    "info",
276    "physics-hash",
277    "help",
278];
279
280/// Parse CLI arguments into a Command.
281pub 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    // Check if first arg is a known subcommand
289    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            // Next arg should be the injection type
403            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
428/// Parse flat --key value args into a HashMap (shared by all subcommands and legacy mode).
429pub 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
515/// Parse an optional float CLI argument, returning a clear error on invalid values.
516fn 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
557/// Build a Cosmology from CosmoOpts.
558pub fn build_cosmology(opts: &CosmoOpts) -> Result<crate::cosmology::Cosmology, String> {
559    // Check for presets first
560    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    // CLI accepts fractional Ω_b and Ω_m; the internal Cosmology struct
574    // wants physical ω_b = Ω_b h² and ω_cdm = (Ω_m − Ω_b) h². Convert here
575    // so the same fractional convention is shared by the Python wrapper,
576    // the Cosmology dataclass, and the CLI.
577    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
623/// Print help text to stderr.
624pub 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
688/// Print cosmology info.
689pub 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
713/// Build an InjectionScenario from CLI arguments.
714///
715/// Returns `Err` for unknown injection types or missing/invalid parameters.
716pub 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
808/// Execute a Green's function calculation. Returns result without doing I/O.
809pub 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
837/// Build a GridConfig from CLI options.
838///
839/// If `production_grid` is set, uses `GridConfig::production()` as the base.
840/// If `n_grid > 0`, overrides `n_points`. Otherwise uses the base defaults.
841fn 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
861/// Build a SolverConfig from CLI solver options with the given z_start.
862fn 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
879/// Extract a human-readable message from a thread panic payload.
880fn 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
889/// Generate the default log-spaced redshift array for photon sweep (150 points from 1e3 to 5e6).
890fn 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
901/// Deduplicate a `Vec<String>` while preserving first-occurrence order.
902/// Used to compress repeated per-worker warnings (e.g. one identical
903/// "z_start in O(theta_e^2) regime" message per sweep redshift) into
904/// a single user-facing line.
905fn 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
917/// Diagnostic-flag warnings collected as plain strings for inclusion in the
918/// CLI result `warnings` array. Each flag is intended for sensitivity probes,
919/// not production runs; bury this in stderr too so notebook users see it.
920fn 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
949/// Apply common solver flags from CLI options to a solver instance.
950fn 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
968/// Validate a (config, grid, injection) combination the same way
969/// `SolverBuilder::build` does, returning the soft warnings that should
970/// surface to the user. Hard errors are propagated as `Err`.
971///
972/// The CLI used to bypass this entirely by going through
973/// `ThermalizationSolver::new` + `set_config`; this helper restores the
974/// validation chain without forcing every call site to use the builder.
975fn 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        // Symmetric check for z_end above the injection window: the injection
995        // never fires in any step (z_end > z_h + 5σ_z). User gets a finite
996        // run with only adiabatic-cooling signal — flag as Err.
997        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    // `warn_stimulated_emission` is intentionally omitted: `set_injection`
1008    // pushes those into the solver's `diag.warnings` directly, so emitting
1009    // them here would double-count.
1010    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
1017/// Execute a single PDE solve. Returns result without doing I/O.
1018pub 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    // For dark photon resonance, start at z_res if z_start isn't set
1038    // explicitly by the user. The IC Δn(x) is installed by the solver at
1039    // z_start via InjectionScenario::initial_delta_n.
1040    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    // DarkPhotonResonance: hard-error if NWA gives no resonance in the
1048    // supported redshift band. Without this the solver runs to mu=y=0 and
1049    // looks like a "successful" null result.
1050    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    // Note: execute_solve uses explicit dy_max/dtau_max from its own defaults,
1109    // not the shared build_solver_config helper, because it has special is_continuous logic.
1110    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
1134/// Execute a sweep over multiple injection redshifts. Returns result without doing I/O.
1135pub 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
1256/// Execute a photon injection sweep over multiple injection redshifts at a fixed x_inj.
1257/// Returns result without doing I/O.
1258pub 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
1396/// Execute a batch photon injection sweep over multiple x_inj values.
1397///
1398/// Flattens all (x_inj, z_h) pairs into a single thread pool, avoiding
1399/// the overhead of spawning separate Rust processes per x_inj.
1400pub 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    // Build flat list of (x_inj_index, z_h_index) tasks
1423    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    // Run tasks in chunks bounded by available parallelism
1431    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    // Group results by x_inj index
1543    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    // Sort each group by z_h for consistent output
1552    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    // ---- Command parsing ----
1595
1596    #[test]
1597    fn test_parse_commands() {
1598        // help
1599        match parse_command(&[s("help")]).unwrap() {
1600            Command::Help => {}
1601            _ => panic!("Expected Help"),
1602        }
1603
1604        // empty → help
1605        match parse_command(&[]).unwrap() {
1606            Command::Help => {}
1607            _ => panic!("Expected Help for empty args"),
1608        }
1609
1610        // solve
1611        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        // sweep
1628        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        // greens
1644        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        // info with cosmology
1651        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        // info default
1658        match parse_command(&[s("info")]).unwrap() {
1659            Command::Info(opts) => assert_eq!(opts.cosmology, "default"),
1660            _ => panic!("Expected Info"),
1661        }
1662
1663        // bare --flags now error rather than falling back to legacy mode
1664        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        // Unknown subcommand
1671        let err = parse_command(&[s("foobar")]).unwrap_err();
1672        assert!(err.contains("Unknown subcommand") && err.contains("foobar"));
1673
1674        // Solve missing injection type
1675        let err = parse_command(&[s("solve")]).unwrap_err();
1676        assert!(err.contains("injection type"));
1677
1678        // Flag as injection type
1679        let err = parse_command(&[s("solve"), s("--z-h"), s("2e5")]).unwrap_err();
1680        assert!(err.contains("--z-h"));
1681
1682        // Greens missing z_h
1683        let err = parse_command(&[s("greens")]).unwrap_err();
1684        assert!(err.contains("--z-h"));
1685
1686        // Greens invalid z_h
1687        let err = parse_command(&[s("greens"), s("--z-h"), s("abc")]).unwrap_err();
1688        assert!(err.contains("Invalid"));
1689
1690        // Invalid format
1691        let err = parse_command(&[s("sweep"), s("--format"), s("xml")]).unwrap_err();
1692        assert!(err.contains("Unknown"));
1693
1694        // Invalid solver opt value
1695        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    // ---- Cosmology building ----
1747
1748    #[test]
1749    fn test_build_cosmology_variants() {
1750        // Presets
1751        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        // Unknown preset returns error (prevents silent use of wrong cosmology)
1773        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        // Custom params: CLI accepts fractional Ω_b, Ω_m and converts.
1780        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        // Stored physical density: ω_b = Ω_b h² = 0.04 × 0.49 = 0.0196.
1790        assert!((custom.omega_b - 0.04 * 0.70 * 0.70).abs() < 1e-10);
1791        // ω_cdm = (Ω_m − Ω_b) h² = 0.26 × 0.49 = 0.1274.
1792        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        // omega_b without omega_m (or vice versa) should error: ω_cdm
1796        // depends on both, and silently drifting is the bug we are
1797        // preventing by requiring them together.
1798        assert!(
1799            build_cosmology(&CosmoOpts {
1800                omega_b: Some(0.04),
1801                ..CosmoOpts::default()
1802            })
1803            .is_err()
1804        );
1805
1806        // Cosmology opts from CLI
1807        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        // --omega-cdm is gone — must error with a helpful message.
1833        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    // ---- Injection scenario building ----
1842
1843    #[test]
1844    fn test_build_injection_all_types() {
1845        // Each (type_name, args, expected_variant_check)
1846        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        // Verify specific field values for single burst
1889        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        // Unknown type
1906        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        // Invalid value
1912        let args = make_args(&[("--f-ann", "not_a_number")]);
1913        assert!(build_injection_scenario("annihilating-dm", &args, 1e-5).is_err());
1914
1915        // Missing required param
1916        let args = make_args(&[("--sigma-z", "100.0")]);
1917        assert!(build_injection_scenario("single-burst", &args, f64::NAN).is_err());
1918    }
1919
1920    // ---- Helpers and misc ----
1921
1922    #[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}