spectroxide/
solver.rs

1//! Main PDE solver for the cosmological thermalization problem.
2//!
3//! Key physics: energy injection → heats electrons (T_e > T_z) →
4//! Kompaneets source drives y-type distortion → DC/BR create photons
5//! to convert y → μ and approach Bose-Einstein equilibrium.
6//!
7//! The Kompaneets equation is split into:
8//! 1. Conservative redistribution at T_e^eq (no net energy change)
9//! 2. Injection source ∝ (ρ_e - ρ_eq) that adds energy from injection
10//!
11//! This ensures energy conservation: only injection adds energy.
12//!
13//! References:
14//! - Chluba & Sunyaev (2012), MNRAS 419, 1294
15
16use crate::bremsstrahlung::{br_emission_coefficient_fast_preln, br_precompute};
17use crate::constants::*;
18use crate::cosmology::Cosmology;
19use crate::double_compton::{dc_high_freq_suppression, dc_prefactor};
20use crate::electron_temp::ElectronTemperature;
21use crate::energy_injection::InjectionScenario;
22use crate::grid::{FrequencyGrid, GridConfig};
23use crate::kompaneets::{KompaneetsWorkspace, kompaneets_step_coupled_inplace};
24use crate::recombination::RecombinationHistory;
25use crate::spectrum::planck;
26
27/// Tunable solver parameters.
28///
29/// Defaults are production-quality and match the values used for all paper
30/// runs. The fields that most users will want to change are [`Self::z_start`],
31/// [`Self::z_end`], and [`Self::dtau_max`].
32#[derive(Debug, Clone)]
33pub struct SolverConfig {
34    /// Upper redshift at which integration begins (solver evolves from
35    /// `z_start` down to `z_end`). Must satisfy `z_start > z_end`.
36    pub z_start: f64,
37    /// Lower redshift at which integration ends.
38    pub z_end: f64,
39    /// Maximum fractional change in ln(1+z) per step (limits step size for
40    /// slow-varying sources). Default 0.02. Historical value 0.005 was
41    /// calibrated for photon-injection bursts; for smooth (continuous) heat
42    /// injection scenarios 0.02 gives the same μ/y to 4 significant figures
43    /// with 5–6× fewer steps, since `dtau_max` takes over as the binding
44    /// constraint at z ≲ 1.5×10⁶.
45    pub dy_max: f64,
46    /// Minimum allowed step size in z; smaller values trigger a warning.
47    /// Default 1e-6.
48    pub dz_min: f64,
49    /// Maximum Compton optical depth per step. With exact exponential DC/BR
50    /// (unconditionally stable), this primarily limits Kompaneets CN accuracy.
51    /// Default 10.0 matches the CLI default (the value used for all paper
52    /// runs). Raise to ~50 for exploratory runs where modest accuracy loss
53    /// is acceptable.
54    pub dtau_max: f64,
55    /// Minimum redshift for number-conserving T-shift subtraction.
56    /// Default 5e4: only subtract at z > nc_z_min where DC/BR is significant.
57    /// Set to 0.0 to subtract at all redshifts (CosmoTherm-like behavior).
58    pub nc_z_min: f64,
59    /// Maximum dtau per step during active photon source injection.
60    /// Limits the Kompaneets Δn² nonlinearity at the injection spike,
61    /// which causes timestep-dependent wake formation at intermediate x.
62    /// Default 1.0 (production quality, ~1% from converged).
63    /// Set to 10.0 for exploratory/fast runs (~3% from converged).
64    pub dtau_max_photon_source: f64,
65    /// Maximum number of Newton iterations per Kompaneets step.
66    /// Default 10. Increase for extreme injection parameters.
67    pub max_newton_iter: usize,
68    /// Use Crank-Nicolson (instead of backward Euler) for DC/BR.
69    /// Second-order in time, matching CosmoTherm's scheme. May be less
70    /// stable at very low x where DC/BR rates diverge.
71    pub cn_dcbr: bool,
72}
73
74impl SolverConfig {
75    /// Validate solver configuration parameters.
76    ///
77    /// Returns `Err` with a descriptive message if any parameter would cause
78    /// the solver to fail or produce meaningless results.
79    pub fn validate(&self) -> Result<(), String> {
80        if !self.z_start.is_finite() || self.z_start <= 0.0 {
81            return Err(format!("z_start must be positive, got {}", self.z_start));
82        }
83        if !self.z_end.is_finite() || self.z_end < 0.0 {
84            return Err(format!("z_end must be non-negative, got {}", self.z_end));
85        }
86        if self.z_start <= self.z_end {
87            return Err(format!(
88                "z_start must be > z_end (solver evolves backwards), got z_start={}, z_end={}",
89                self.z_start, self.z_end
90            ));
91        }
92        if !self.dy_max.is_finite() || self.dy_max <= 0.0 {
93            return Err(format!("dy_max must be positive, got {}", self.dy_max));
94        }
95        // Guardrail on the upper end: dy_max controls the fractional change
96        // in ln(1+z) per step, and the adaptive-step derivation assumes this
97        // is small. Default is 0.02; at 0.1 the linearization begins to fail
98        // and step-count savings are illusory since dtau_max takes over.
99        if self.dy_max > 0.1 {
100            return Err(format!(
101                "dy_max={} exceeds safe limit 0.1. Large dy_max invalidates the \
102                 adaptive-stepping linearization; use a smaller value (default 0.02).",
103                self.dy_max
104            ));
105        }
106        if !self.dz_min.is_finite() || self.dz_min <= 0.0 {
107            return Err(format!("dz_min must be positive, got {}", self.dz_min));
108        }
109        if !self.dtau_max.is_finite() || self.dtau_max <= 0.0 {
110            return Err(format!("dtau_max must be positive, got {}", self.dtau_max));
111        }
112        if self.max_newton_iter < 2 {
113            return Err(format!(
114                "max_newton_iter must be >= 2, got {}",
115                self.max_newton_iter
116            ));
117        }
118        // Fokker-Planck validity: theta_e ~ 4.6e-10 * (1+z), so z > 5e6 gives
119        // theta_e > 2.3e-3 where O(theta_e^2) corrections become significant.
120        // At z > ~1e7 the Kompaneets equation is qualitatively wrong.
121        if self.z_start > 1.0e7 {
122            return Err(format!(
123                "z_start={:.1e} implies theta_e ~ {:.1e}; Kompaneets Fokker-Planck \
124                 approximation is invalid for theta_e > ~0.005. Max z_start ~ 1e7.",
125                self.z_start,
126                4.6e-10 * (1.0 + self.z_start)
127            ));
128        }
129        Ok(())
130    }
131
132    /// Collect non-fatal validation warnings (e.g. regimes where the
133    /// Kompaneets Fokker-Planck approximation begins to show O(θ_e²)
134    /// corrections but is not outright invalid).
135    pub(crate) fn soft_warnings(&self) -> Vec<String> {
136        let mut out = Vec::new();
137        if self.z_start > 5.0e6 {
138            out.push(format!(
139                "z_start={:.1e} implies theta_e ~ {:.1e}. Kompaneets Fokker-Planck has \
140                 O(theta_e^2) corrections that grow above z ~ 5e6. Results may have ~1% \
141                 systematic errors.",
142                self.z_start,
143                4.6e-10 * (1.0 + self.z_start)
144            ));
145        }
146        if self.dy_max > 0.05 {
147            out.push(format!(
148                "dy_max={:.3} is well above the default 0.02; step-size linearization \
149                 errors grow rapidly past ~0.05. Validate against a smaller dy_max \
150                 before trusting quantitative results.",
151                self.dy_max
152            ));
153        }
154        out
155    }
156}
157
158impl Default for SolverConfig {
159    fn default() -> Self {
160        SolverConfig {
161            z_start: 3.0e6,
162            z_end: 1.0,
163            dy_max: 0.02,
164            dz_min: 1e-6,
165            dtau_max: 10.0,
166            nc_z_min: 5.0e4,
167            dtau_max_photon_source: 1.0,
168            max_newton_iter: 10,
169            cn_dcbr: false,
170        }
171    }
172}
173
174/// State of the photon distribution at a single redshift.
175///
176/// Returned by [`ThermalizationSolver::run_with_snapshots`]. The distortion
177/// amplitudes (`mu`, `y`, accumulated `delta_t`) are extracted from `delta_n`
178/// by a joint least-squares decomposition; see [`crate::distortion`].
179#[derive(Debug, Clone)]
180pub struct SolverSnapshot {
181    /// Redshift of this snapshot.
182    pub z: f64,
183    /// Photon occupation-number perturbation `Δn(x) = n(x) − n_Planck(x)`
184    /// on the solver's frequency grid.
185    pub delta_n: Vec<f64>,
186    /// Electron-to-photon temperature ratio `ρ_e = T_e / T_z` at this redshift.
187    pub rho_e: f64,
188    /// Bose-Einstein chemical-potential-like distortion amplitude μ.
189    pub mu: f64,
190    /// Compton y-parameter distortion amplitude.
191    pub y: f64,
192    /// Fractional energy injected by the active scenario up to this
193    /// redshift, `Δρ / ρ`.
194    pub delta_rho_over_rho: f64,
195    /// Cumulative temperature shift `ΔT/T` subtracted by the number-conserving
196    /// machinery (non-zero only when `number_conserving` is enabled).
197    pub accumulated_delta_t: f64,
198}
199
200impl SolverSnapshot {
201    /// Brightness-temperature deviation `T(x)/T_CMB − 1` on the given grid.
202    pub fn brightness_temp(&self, x_grid: &[f64]) -> Vec<f64> {
203        x_grid
204            .iter()
205            .enumerate()
206            .map(|(i, &x)| {
207                let n_total = planck(x) + self.delta_n[i];
208                if n_total <= 0.0 || !n_total.is_finite() {
209                    return 0.0;
210                }
211                x / (1.0 + 1.0 / n_total).ln() - 1.0
212            })
213            .collect()
214    }
215}
216
217/// Cached backward-Euler ODE coefficients for the ρ_e equation.
218///
219/// Computed once per step in `update_temperatures()` and consumed by:
220/// 1. The DC/BR emission rate computation (needs ρ_dcbr corrected for injection).
221/// 2. The bordered Newton solver (couples ρ_e into the Kompaneets iteration).
222#[derive(Debug, Clone, Copy)]
223struct RhoECache {
224    /// ρ_e at the start of the step (before Newton iteration).
225    rho_e_old: f64,
226    /// Compton coupling coefficient R = t_C / dtau × (some prefactor).
227    r_compton: f64,
228    /// RHS source: ρ_eq + δρ_inj (unscaled; R applied at point of use).
229    rho_source: f64,
230    /// H × t_C (Hubble expansion coupling).
231    lambda_htc: f64,
232    /// dH/dρ_e: derivative of DC+BR heating integral w.r.t. ρ_e.
233    dh_drho: f64,
234    /// Compton optical depth for this step (used for the DC/BR injection correction).
235    dtau: f64,
236}
237
238/// Diagnostic counters and extrema tracked during solver evolution.
239///
240/// Grouped into a sub-struct to keep `ThermalizationSolver` focused on
241/// physics state. Reset by `ThermalizationSolver::reset()`.
242#[derive(Debug, Clone, Default)]
243pub struct SolverDiagnostics {
244    /// Number of times rho_e was clamped.
245    /// Non-zero values indicate injection energy may be silently discarded.
246    pub rho_e_clamped: usize,
247    /// Number of times Newton iteration exhausted max_newton_iter
248    /// without converging. Non-zero values indicate the solver may need more
249    /// iterations or smaller step sizes.
250    pub newton_exhausted: usize,
251    /// Maximum uncapped emission rate encountered (NaN excluded).
252    pub max_emission_rate: f64,
253    /// Whether any NaN emission rate was encountered.
254    pub nan_emission_detected: bool,
255    /// Warning messages collected during solver evolution.
256    /// Replaces eprintln! in library code for structured diagnostics.
257    pub warnings: Vec<String>,
258}
259
260/// Full PDE solver for CMB spectral distortions.
261///
262/// Evolves the photon occupation number perturbation Δn(x, z) from `z_start`
263/// down to `z_end` using a coupled implicit scheme:
264/// - Crank-Nicolson for Kompaneets (frequency redistribution)
265/// - Backward Euler for DC/BR emission (photon-number changing)
266/// - Newton iteration coupling all three simultaneously
267///
268/// # Lifecycle
269///
270/// ```text
271/// ThermalizationSolver::new(cosmo, grid_config)   // or ::builder(...)
272///   → set_injection(scenario)                      // optional
273///   → run_with_snapshots(&[z1, z2, ...])           // evolve the PDE
274///   → snapshots[i].{mu, y, delta_t, ...}           // read results
275/// ```
276///
277/// The solver can be re-run after calling `reset()`, which clears Δn and
278/// diagnostic counters but keeps the cosmology and grid.
279///
280/// # Energy injection
281///
282/// Call `set_injection()` before running. Without an injection, Δn stays
283/// near zero (only adiabatic cooling acts). Multiple runs with different
284/// injections require `reset()` between runs.
285///
286/// # Configuration
287///
288/// See [`SolverConfig`] for timestep and physics options, and [`GridConfig`]
289/// for frequency grid options. The builder API (`ThermalizationSolver::builder`)
290/// provides a fluent interface for all configuration.
291///
292/// # Field visibility (unstable API)
293///
294/// Many fields are currently `pub` for use by examples, tests, and diagnostic
295/// tooling. These are **not part of the stable public API** and may become
296/// `pub(crate)` in a future release. Mutating state fields (`delta_n`, `z`,
297/// `electron_temp`, `snapshots`, `step_count`, `accumulated_delta_t`) between
298/// `run_with_snapshots` calls can break Newton-solver invariants; prefer the
299/// builder and `set_injection` / `reset` methods for all changes of consequence.
300pub struct ThermalizationSolver {
301    /// Timestepping and physics-toggle configuration.
302    pub config: SolverConfig,
303    /// Background cosmology.
304    pub cosmo: Cosmology,
305    /// Frequency grid (non-uniform in x).
306    pub grid: FrequencyGrid,
307    /// Current photon occupation-number perturbation `Δn(x)` on the grid.
308    pub delta_n: Vec<f64>,
309    /// Electron temperature state `ρ_e = T_e/T_z` (perturbative
310    /// quasi-stationary solve).
311    pub electron_temp: ElectronTemperature,
312    /// Current redshift of the integration.
313    pub z: f64,
314    /// Active injection scenario, if any. Set via [`Self::set_injection`].
315    pub injection: Option<InjectionScenario>,
316    /// Snapshots collected during the last run. Populated by
317    /// [`Self::run_with_snapshots`] / [`Self::run`].
318    pub snapshots: Vec<SolverSnapshot>,
319    /// Number of timesteps taken in the current run.
320    pub step_count: usize,
321    /// Compton equilibrium ρ_eq = I₄/(4G₃) from the photon spectrum
322    rho_eq: f64,
323    /// Cached recombination history for fast X_e(z) lookups
324    recomb: RecombinationHistory,
325    /// Initial photon perturbation Δn(x) to use instead of zeros.
326    /// Consumed (taken) by run_with_snapshots on first call.
327    initial_delta_n: Option<Vec<f64>>,
328    /// If true, disable DC/BR processes (Kompaneets only). For diagnostics.
329    pub disable_dcbr: bool,
330    /// If true (default), couple DC/BR into the Kompaneets Newton iteration
331    /// instead of operator splitting. Uses IMEX: Crank-Nicolson for Kompaneets
332    /// + backward Euler for DC/BR, solved simultaneously. More physically
333    /// consistent than operator splitting, especially at z > 2×10⁶.
334    pub coupled_dcbr: bool,
335    /// Pre-allocated work buffer for DC/BR emission rates (per step)
336    emission_rates: Vec<f64>,
337    /// Pre-allocated work buffer for DC/BR equilibrium minus Planck
338    n_eq_minus_n_pl: Vec<f64>,
339    /// d(emission_rates)/d(ρ_eq), analytical. Used by the bordered Newton
340    /// c-vector to close the Δn-row Jacobian on ρ_e (otherwise the solve
341    /// is only linearly convergent in the ρ_e direction when DC/BR is
342    /// strong, e.g. at z ≳ 10⁶ or during a photon-injection burst).
343    dem_drho_eq: Vec<f64>,
344    /// d(n_eq_minus_n_pl)/d(ρ_eq), analytical. See `dem_drho_eq`.
345    dneq_drho_eq: Vec<f64>,
346    /// Pre-allocated Kompaneets workspace (grid-constant arrays + per-step buffers)
347    komp_ws: KompaneetsWorkspace,
348    /// Precomputed Planck spectrum on the grid: planck(x[i])
349    planck_grid: Vec<f64>,
350    /// Diagnostic: scale factor for DC/BR emission rates. Default 1.0.
351    /// Set to < 1.0 to test whether rates are too strong.
352    pub dcbr_scale: f64,
353    /// Subtract the temperature shift component from Δn after each
354    /// DC/BR step at z > 5×10⁴, enforcing photon number conservation
355    /// (∫x² Δn dx = 0). This prevents DC/BR-created photons from
356    /// accumulating as a spurious temperature shift that feeds back into
357    /// over-thermalization. See Chluba (2013), arXiv:1304.6120.
358    /// Enabled by default.
359    pub number_conserving: bool,
360    /// Apply NC stripping every nc_stride steps (default 1 = every step).
361    /// Higher values reduce NC-DC/BR feedback at high z.
362    pub nc_stride: usize,
363    /// Cumulative δT/T subtracted from Δn by number conservation.
364    /// Added back to ρ_eq so T_e tracks the true (shifted) reference.
365    pub accumulated_delta_t: f64,
366    /// Precomputed g_bb(x[i]) = x e^x / (e^x - 1)² on the grid.
367    /// Used by subtract_temperature_shift() to avoid recomputation.
368    g_bb_grid: Vec<f64>,
369    /// Discrete quadrature ∫x²·g_bb·dx using the same trapezoidal rule as
370    /// the number integral. Used by subtract_temperature_shift() to ensure
371    /// exact photon number conservation on the discrete grid, avoiding the
372    /// O(N⁻²) drift from using the analytical 3·G₂.
373    g2_gbb_discrete: f64,
374    /// Diagnostic counters and extrema tracked during evolution.
375    pub diag: SolverDiagnostics,
376    /// Precomputed DC high-frequency suppression: exp(-2x) × polynomial(x) for each grid point.
377    /// Depends only on x, computed once at construction.
378    dc_suppression_grid: Vec<f64>,
379    /// Precomputed DC suppression at cell midpoints x_half[i] = (x[i]+x[i+1])/2.
380    /// Used in the DC/BR heating integral (dcbr_heating_with_derivative), which
381    /// evaluates K_DC at midpoints rather than grid nodes.
382    dc_suppression_half: Vec<f64>,
383    /// Precomputed exp(x) - 1 for each grid point (for Bose factor Taylor expansion).
384    exp_m1_grid: Vec<f64>,
385    /// Precomputed exp(x) for each grid point (for Bose factor Taylor expansion).
386    exp_grid: Vec<f64>,
387    /// Precomputed ln(x) for each grid point (for BR Gaunt factor).
388    ln_x_grid: Vec<f64>,
389    /// Precomputed ln(x_half) for each cell midpoint (for BR Gaunt factor in heating integral).
390    ln_x_half: Vec<f64>,
391    /// Precomputed trapezoidal quadrature weights for ∫x² f(x) dx (per grid node).
392    /// Used by subtract_temperature_shift() instead of recomputing x_half² × dx.
393    quad_weights_x2: Vec<f64>,
394    /// Pre-allocated buffer for photon source injection (source_rate × dt per grid point).
395    /// Photons are injected directly into Δn; DC/BR handles absorption during
396    /// the coupled step, with h_dc_br self-consistently feeding back to ρ_e.
397    photon_source_buf: Vec<f64>,
398    /// Cached backward Euler ODE coefficients from `update_temperatures()`.
399    /// Used by the bordered Newton solver to couple ρ_e into the Kompaneets step.
400    rho_e_ode_cache: Option<RhoECache>,
401}
402
403/// Compute DC+BR heating integral and optionally its finite-difference
404/// derivative dH/dρ_e in a single pass over the frequency grid.
405///
406/// **Performance optimization**: DC emission coefficients K_DC(x, θ_z) are
407/// independent of θ_e, so they're computed once and reused for all 3 FD
408/// evaluations (current, +δ, -δ). Grid midpoints, dx, and n_mid are also
409/// computed once. This replaces 6 separate O(N) grid sweeps with 1 combined
410/// sweep (or 1 sweep + 2 lightweight inner loops for the FD passes).
411///
412/// Returns (h_dc_br, dh_drho).
413fn dcbr_heating_with_derivative(
414    x_grid: &[f64],
415    delta_n: &[f64],
416    theta_z: f64,
417    theta_e: f64,
418    n_h: f64,
419    n_he: f64,
420    n_e: f64,
421    x_e_frac: f64,
422    y_he_ii: f64,
423    y_he_i: f64,
424    compute_derivative: bool,
425    ln_x_half: &[f64],
426    dc_supp_half: &[f64],
427) -> (f64, f64) {
428    use crate::bremsstrahlung::{br_emission_coefficient_fast_preln, br_precompute};
429    use crate::spectrum::planck;
430
431    if theta_e < 1e-30 || n_e < 1e-30 {
432        return (0.0, 0.0);
433    }
434
435    let phi = theta_z / theta_e;
436    let norm = 1.0 / (4.0 * G3_PLANCK * theta_z);
437    let n = x_grid.len();
438
439    // Single pass: compute h_dc_br at current θ_e
440    let mut integral = 0.0;
441
442    // If computing derivative, also accumulate +/- integrals
443    let delta_rho_fd = 1e-4;
444    let rho_e = theta_e / theta_z;
445    let phi_plus = theta_z / (theta_z * (rho_e + delta_rho_fd));
446    let phi_minus = theta_z / (theta_z * (rho_e - delta_rho_fd));
447    let theta_e_plus = theta_z * (rho_e + delta_rho_fd);
448    let theta_e_minus = theta_z * (rho_e - delta_rho_fd);
449
450    let mut integral_plus = 0.0;
451    let mut integral_minus = 0.0;
452
453    // Hoist x-independent DC prefactor out of the grid loop
454    let dc_pre = dc_prefactor(theta_z);
455
456    // Precompute BR for the 3 θ_e values. At function entry we've already
457    // rejected theta_e < 1e-30 and n_e < 1e-30, so `br_precompute` here
458    // cannot return `None`. Unwrapping outside the grid loop eliminates
459    // per-cell Option-match overhead and lets LLVM auto-vectorize the body
460    // (the inner call gets inlined + the straight-line flow is SIMD-able
461    // because `planck`, `exp_m1`, `exp`, `ln` all auto-vec at -C
462    // target-cpu=native on AVX-512 hardware).
463    let br_pre = br_precompute(theta_e, theta_z, n_h, n_he, n_e, x_e_frac, y_he_ii, y_he_i)
464        .expect("br_precompute: entry checks guarantee Some");
465
466    // Derivative only runs when both FD shifts stay positive. If either
467    // `theta_e_plus` / `theta_e_minus` would underflow `br_precompute`'s
468    // positivity guard (ρ_e ≲ δ_FD = 1e-4, extremely uncommon), fall back
469    // to the no-derivative path and return dh = 0.
470    let fd_pre = if compute_derivative {
471        match (
472            br_precompute(
473                theta_e_plus,
474                theta_z,
475                n_h,
476                n_he,
477                n_e,
478                x_e_frac,
479                y_he_ii,
480                y_he_i,
481            ),
482            br_precompute(
483                theta_e_minus,
484                theta_z,
485                n_h,
486                n_he,
487                n_e,
488                x_e_frac,
489                y_he_ii,
490                y_he_i,
491            ),
492        ) {
493            (Some(p), Some(m)) => Some((p, m)),
494            _ => None,
495        }
496    } else {
497        None
498    };
499
500    if let Some((br_pre_plus, br_pre_minus)) = fd_pre {
501        for i in 1..n {
502            let dx = x_grid[i] - x_grid[i - 1];
503            let x_mid = 0.5 * (x_grid[i] + x_grid[i - 1]);
504            let dn_mid = 0.5 * (delta_n[i] + delta_n[i - 1]);
505            let n_mid = planck(x_mid) + dn_mid;
506            let ln_xm = ln_x_half[i - 1];
507
508            let k_dc = dc_pre * dc_supp_half[i - 1];
509            let k_br = br_emission_coefficient_fast_preln(x_mid, ln_xm, &br_pre);
510            let k_br_p = br_emission_coefficient_fast_preln(x_mid, ln_xm, &br_pre_plus);
511            let k_br_m = br_emission_coefficient_fast_preln(x_mid, ln_xm, &br_pre_minus);
512
513            let x_e = x_mid * phi;
514            let x_e_p = x_mid * phi_plus;
515            let x_e_m = x_mid * phi_minus;
516            let em = x_e.exp_m1();
517            let em_p = x_e_p.exp_m1();
518            let em_m = x_e_m.exp_m1();
519
520            integral += (1.0 - n_mid * em) * (k_dc + k_br) * dx;
521            integral_plus += (1.0 - n_mid * em_p) * (k_dc + k_br_p) * dx;
522            integral_minus += (1.0 - n_mid * em_m) * (k_dc + k_br_m) * dx;
523        }
524    } else {
525        for i in 1..n {
526            let dx = x_grid[i] - x_grid[i - 1];
527            let x_mid = 0.5 * (x_grid[i] + x_grid[i - 1]);
528            let dn_mid = 0.5 * (delta_n[i] + delta_n[i - 1]);
529            let n_mid = planck(x_mid) + dn_mid;
530            let ln_xm = ln_x_half[i - 1];
531
532            let k_dc = dc_pre * dc_supp_half[i - 1];
533            let k_br = br_emission_coefficient_fast_preln(x_mid, ln_xm, &br_pre);
534
535            let x_e = x_mid * phi;
536            let factor = 1.0 - n_mid * x_e.exp_m1();
537            integral += factor * (k_dc + k_br) * dx;
538        }
539    }
540
541    let h = integral * norm;
542    let dh = if compute_derivative {
543        (integral_plus * norm - integral_minus * norm) / (2.0 * delta_rho_fd)
544    } else {
545        0.0
546    };
547
548    (h, dh)
549}
550
551impl ThermalizationSolver {
552    /// Construct a solver with the given cosmology and frequency grid.
553    ///
554    /// All other state (solver config, injection, flags) is set to defaults;
555    /// mutate the public fields or call [`Self::set_injection`] /
556    /// [`Self::set_config`] before running. For a fluent API that performs
557    /// validation at build time, use [`Self::builder`] instead.
558    pub fn new(cosmo: Cosmology, grid_config: GridConfig) -> Self {
559        let grid = FrequencyGrid::new(&grid_config);
560        let n = grid.n;
561        let recomb = RecombinationHistory::new(&cosmo);
562        let komp_ws = KompaneetsWorkspace::new(&grid);
563        let planck_grid: Vec<f64> = grid.x.iter().map(|&x| planck(x)).collect();
564        let g_bb_grid: Vec<f64> = grid.x.iter().map(|&x| crate::spectrum::g_bb(x)).collect();
565        let dc_suppression_grid: Vec<f64> = grid
566            .x
567            .iter()
568            .map(|&x| dc_high_freq_suppression(x))
569            .collect();
570        let dc_suppression_half: Vec<f64> = grid
571            .x_half
572            .iter()
573            .map(|&x| dc_high_freq_suppression(x))
574            .collect();
575        // Overflow at x > 500 produces +∞; downstream `is_finite` checks then
576        // reject the emission rate. Using f64::MAX would pass through those
577        // guards as a huge finite number (see audit H2).
578        let exp_m1_grid: Vec<f64> = grid
579            .x
580            .iter()
581            .map(|&x| if x > 500.0 { f64::INFINITY } else { x.exp_m1() })
582            .collect();
583        let exp_grid: Vec<f64> = grid
584            .x
585            .iter()
586            .map(|&x| if x > 500.0 { f64::INFINITY } else { x.exp() })
587            .collect();
588        let ln_x_grid: Vec<f64> = grid
589            .x
590            .iter()
591            .map(|&x| if x < 1e-30 { -69.0 } else { x.ln() })
592            .collect();
593        let ln_x_half: Vec<f64> = grid
594            .x_half
595            .iter()
596            .map(|&x| if x < 1e-30 { -69.0 } else { x.ln() })
597            .collect();
598        let quad_weights_x2: Vec<f64> = {
599            let mut w = vec![0.0; n];
600            for j in 1..n {
601                let xh = grid.x_half[j - 1];
602                let half_w = 0.5 * xh * xh * grid.dx[j - 1];
603                w[j - 1] += half_w;
604                w[j] += half_w;
605            }
606            w
607        };
608        let g2_gbb_discrete: f64 = {
609            let mut sum = 0.0;
610            for i in 1..grid.n {
611                let gbb_mid = 0.5 * (g_bb_grid[i] + g_bb_grid[i - 1]);
612                let x_half = grid.x_half[i - 1];
613                sum += x_half * x_half * gbb_mid * grid.dx[i - 1];
614            }
615            sum
616        };
617        ThermalizationSolver {
618            config: SolverConfig::default(),
619            cosmo,
620            grid,
621            delta_n: vec![0.0; n],
622            electron_temp: ElectronTemperature::default(),
623            z: SolverConfig::default().z_start,
624            injection: None,
625            snapshots: Vec::new(),
626            step_count: 0,
627            rho_eq: 1.0,
628            recomb,
629            initial_delta_n: None,
630            disable_dcbr: false,
631            coupled_dcbr: true,
632            emission_rates: vec![0.0; n],
633            n_eq_minus_n_pl: vec![0.0; n],
634            dem_drho_eq: vec![0.0; n],
635            dneq_drho_eq: vec![0.0; n],
636            komp_ws,
637            planck_grid,
638            dcbr_scale: 1.0,
639            number_conserving: true,
640            nc_stride: 1,
641            accumulated_delta_t: 0.0,
642            g_bb_grid,
643            g2_gbb_discrete,
644            diag: SolverDiagnostics::default(),
645            rho_e_ode_cache: None,
646            dc_suppression_grid,
647            dc_suppression_half,
648            exp_m1_grid,
649            exp_grid,
650            ln_x_grid,
651            ln_x_half,
652            quad_weights_x2,
653            photon_source_buf: vec![0.0; n],
654        }
655    }
656
657    /// Attach an energy-injection scenario, validating it first.
658    ///
659    /// Returns `Err` if the scenario parameters are unphysical (e.g. negative
660    /// widths, impossible masses). Collects stimulated-emission warnings into
661    /// [`SolverDiagnostics::warnings`].
662    pub fn set_injection(&mut self, scenario: InjectionScenario) -> Result<(), String> {
663        scenario.validate()?;
664
665        for warning in scenario.warn_stimulated_emission() {
666            self.diag.warnings.push(warning);
667        }
668
669        // Surface the case where the caller built a grid that doesn't span
670        // the injection frequency. The refinement zone is silently clipped
671        // to [x_min, x_max] during grid construction (see
672        // energy_injection.rs::refinement_zones), so the injection spike
673        // goes unresolved. The builder auto-refines via `suggested_x_min`,
674        // but a bare `ThermalizationSolver::new` + `set_injection` bypasses
675        // that path — warn the caller rather than silently mis-resolving.
676        let x_min = self.grid.x.first().copied().unwrap_or(0.0);
677        let x_max = self.grid.x.last().copied().unwrap_or(f64::INFINITY);
678        let x_inj_opt = match &scenario {
679            InjectionScenario::MonochromaticPhotonInjection { x_inj, .. } => Some(*x_inj),
680            InjectionScenario::DecayingParticlePhoton { x_inj_0, .. } => Some(*x_inj_0),
681            _ => None,
682        };
683        if let Some(x_inj) = x_inj_opt {
684            if x_inj < x_min {
685                self.diag.warnings.push(format!(
686                    "Injection frequency x_inj={x_inj:.3e} is below the grid's x_min={x_min:.3e}. \
687                     The refinement zone will be clipped away and the injection spike \
688                     will not be resolved. Rebuild the grid with a lower x_min (see \
689                     InjectionScenario::suggested_x_min) or use SolverBuilder, which \
690                     auto-refines."
691                ));
692            } else if x_inj > x_max {
693                self.diag.warnings.push(format!(
694                    "Injection frequency x_inj={x_inj:.3e} is above the grid's x_max={x_max:.3e}. \
695                     The injection source will be silently zero: the grid cannot represent \
696                     photons above x_max. Extend x_max to cover x_inj + a few σ_x."
697                ));
698            }
699        }
700
701        self.injection = Some(scenario);
702        Ok(())
703    }
704
705    /// Replace the solver configuration and reset the current redshift to
706    /// the new `z_start`.
707    pub fn set_config(&mut self, config: SolverConfig) {
708        self.z = config.z_start;
709        self.config = config;
710    }
711
712    /// Set an initial photon perturbation Δn(x) for the next PDE run.
713    ///
714    /// This replaces the default Δn = 0 initialization in `run_with_snapshots`.
715    /// The perturbation is consumed (taken) on the next call to `run_with_snapshots`.
716    ///
717    /// Panics if any entry is non-finite — silently passing NaN/Inf into the
718    /// solver causes a deep panic in the Newton step where the source isn't
719    /// obvious. Caught early here so the user sees the real culprit.
720    pub fn set_initial_delta_n(&mut self, delta_n: Vec<f64>) {
721        assert_eq!(
722            delta_n.len(),
723            self.grid.n,
724            "initial_delta_n length {} != grid size {}",
725            delta_n.len(),
726            self.grid.n
727        );
728        if let Some((idx, val)) = delta_n.iter().enumerate().find(|(_, v)| !v.is_finite()) {
729            panic!(
730                "initial_delta_n contains non-finite value at index {idx} (={val}); \
731                 this would silently propagate into Δn and abort the solver in a less \
732                 informative location. Reject NaN/Inf at the user input boundary."
733            );
734        }
735        self.initial_delta_n = Some(delta_n);
736    }
737
738    /// Reset solver state for reuse, keeping grid and recombination cache.
739    ///
740    /// Restores all configuration flags to their defaults: a reset solver
741    /// behaves identically to a freshly constructed one (aside from the
742    /// cached grid/recombination tables, which are preserved for speed).
743    pub fn reset(&mut self) {
744        for v in self.delta_n.iter_mut() {
745            *v = 0.0;
746        }
747        self.electron_temp = ElectronTemperature::default();
748        self.rho_eq = 1.0;
749        self.injection = None;
750        self.snapshots.clear();
751        self.step_count = 0;
752        self.initial_delta_n = None;
753        self.rho_e_ode_cache = None;
754        self.accumulated_delta_t = 0.0;
755        self.diag = SolverDiagnostics::default();
756        self.config = SolverConfig::default();
757        self.z = self.config.z_start;
758        self.disable_dcbr = false;
759        self.coupled_dcbr = true;
760        self.number_conserving = true;
761        self.nc_stride = 1;
762        self.dcbr_scale = 1.0;
763        for v in self.emission_rates.iter_mut() {
764            *v = 0.0;
765        }
766        for v in self.n_eq_minus_n_pl.iter_mut() {
767            *v = 0.0;
768        }
769        for v in self.dem_drho_eq.iter_mut() {
770            *v = 0.0;
771        }
772        for v in self.dneq_drho_eq.iter_mut() {
773            *v = 0.0;
774        }
775        for v in self.photon_source_buf.iter_mut() {
776            *v = 0.0;
777        }
778    }
779
780    fn x_e_at(&self, z: f64) -> f64 {
781        self.recomb.x_e(z)
782    }
783
784    fn adaptive_dz(&self) -> f64 {
785        let x_e = self.x_e_at(self.z);
786        let t_c = self.cosmo.t_compton(self.z, x_e);
787        let h = self.cosmo.hubble(self.z);
788        let theta_e_val = self.electron_temp.theta_e_with(self.cosmo.theta_z(self.z));
789        if theta_e_val < 1e-30 {
790            return self.config.dz_min;
791        }
792        let mut dz = self.config.dy_max * t_c * h * (1.0 + self.z) / theta_e_val;
793
794        // Cap the Compton optical depth (dtau = dz / (H*(1+z)*t_C)) to limit
795        // DC/BR backward Euler over-thermalization error.
796        if self.config.dtau_max > 0.0 {
797            let dz_from_dtau = self.config.dtau_max * h * (1.0 + self.z) * t_c;
798            dz = dz.min(dz_from_dtau);
799        }
800
801        // Limit step size during active injection to resolve the heating profile.
802        // At least 10 steps per sigma within the ±5σ window.
803        // Before the injection (z > z_h + 5σ), allow larger steps but ensure
804        // we don't overshoot the injection window entirely.
805        // Applies to both SingleBurst and MonochromaticPhotonInjection.
806        let burst_params: Option<(f64, f64)> = match &self.injection {
807            Some(InjectionScenario::SingleBurst { z_h, sigma_z, .. }) => Some((*z_h, *sigma_z)),
808            Some(InjectionScenario::MonochromaticPhotonInjection { z_h, sigma_z, .. }) => {
809                Some((*z_h, *sigma_z))
810            }
811            _ => None,
812        };
813        let is_photon_source = matches!(
814            &self.injection,
815            Some(InjectionScenario::MonochromaticPhotonInjection { .. })
816                | Some(InjectionScenario::DecayingParticlePhoton { .. })
817                | Some(InjectionScenario::TabulatedPhotonSource { .. })
818        );
819        if let Some((z_h, sigma_z)) = burst_params {
820            let z_upper = z_h + 5.0 * sigma_z;
821            let z_lower = z_h - 5.0 * sigma_z;
822            if self.z >= z_lower && self.z <= z_upper {
823                // Inside injection window: fine stepping
824                dz = dz.min(sigma_z / 10.0);
825                // For photon injection, also limit dtau to resolve the
826                // Kompaneets Δn² nonlinearity at the injection spike.
827                // Without this, the spike amplitude Δn >> n_pl causes
828                // timestep-dependent Kompaneets scattering (wake).
829                if is_photon_source && self.config.dtau_max_photon_source > 0.0 {
830                    let dz_from_dtau_photon =
831                        self.config.dtau_max_photon_source * h * (1.0 + self.z) * t_c;
832                    dz = dz.min(dz_from_dtau_photon);
833                }
834            } else if self.z > z_upper {
835                // Pre-injection: coast with larger steps (10× normal dy_max step)
836                // to avoid wasting thousands of fine steps before the burst begins.
837                // NEVER overshoot the injection window boundary.
838                let dz_coast = 10.0 * self.config.dy_max * t_c * h * (1.0 + self.z) / theta_e_val;
839                dz = dz.max(dz_coast);
840                // Respect dtau_max
841                if self.config.dtau_max > 0.0 {
842                    let dz_from_dtau = self.config.dtau_max * h * (1.0 + self.z) * t_c;
843                    dz = dz.min(dz_from_dtau);
844                }
845                // Clamp so we land at the injection window boundary, not beyond it
846                let dz_to_window = self.z - z_upper;
847                if dz > dz_to_window && dz_to_window > 0.0 {
848                    // Step to just outside the injection window (stop above z_upper)
849                    dz = dz_to_window - sigma_z / 10.0;
850                    if dz < 0.0 {
851                        dz = dz_to_window;
852                    }
853                }
854            }
855            // Post-injection (z < z_lower): normal adaptive stepping
856        }
857
858        dz.max(self.config.dz_min).min(self.z * 0.05)
859    }
860
861    /// Update ρ_e from distortion feedback + injection.
862    ///
863    /// Two modes selected automatically by distortion amplitude:
864    /// - **Small Δn** (|ΔG₃/G₃| ≤ 0.1): Perturbative Δρ_eq = ΔI₄/(4G₃) - ΔG₃/G₃.
865    ///   Avoids the 0.1% cancellation error in the full I₄/(4G₃) computation.
866    /// - **Large Δn** (|ΔG₃/G₃| > 0.1): Exact ρ_eq = I₄/(4G₃) using the full
867    ///   occupation number n = n_pl + Δn. Retains the Δn² term in I₄ and the
868    ///   nonlinear denominator. Necessary for strong depletions (e.g., dark photon
869    ///   conversions with γ_con ~ O(1)) where the perturbative expansion breaks down.
870    ///
871    /// Returns (x_e, t_c, theta_z, max_dn_abs, dtau, hubble) at z_eval for reuse
872    /// by the caller, avoiding redundant recomputation.
873    fn update_temperatures(
874        &mut self,
875        z_eval: f64,
876        actual_dz: f64,
877    ) -> (f64, f64, f64, f64, f64, f64) {
878        // Fused max|Δn| scan + spectral integrals: single pass over delta_n
879        // to avoid a redundant O(n) traversal. NaN detection is folded in.
880        let mut max_dn: f64 = 0.0;
881        let mut delta_i4 = 0.0;
882        let mut delta_g3 = 0.0;
883        let mut exact_i4 = 0.0;
884        let mut exact_g3 = 0.0;
885        // First element contributes to max_dn but not to the midpoint integrals
886        let abs0 = self.delta_n[0].abs();
887        if abs0.is_nan() {
888            max_dn = f64::NAN;
889        } else if abs0 > max_dn {
890            max_dn = abs0;
891        }
892        for i in 1..self.grid.n {
893            // Track max|Δn| with NaN propagation
894            let abs_dn = self.delta_n[i].abs();
895            if abs_dn.is_nan() || max_dn.is_nan() {
896                max_dn = f64::NAN;
897            } else if abs_dn > max_dn {
898                max_dn = abs_dn;
899            }
900            // Spectral integrals (always computed; ~free since we're already touching the data)
901            let dx = self.grid.dx[i - 1];
902            let x_half = self.grid.x_half[i - 1];
903            let x3 = self.grid.x_half_cubed[i - 1];
904            let dn_mid = 0.5 * (self.delta_n[i] + self.delta_n[i - 1]);
905            let n_pl = 0.5 * (self.planck_grid[i] + self.planck_grid[i - 1]);
906            delta_g3 += x3 * dn_mid * dx;
907            delta_i4 += x3 * x_half * (2.0 * n_pl + 1.0) * dn_mid * dx;
908            let n_full = (n_pl + dn_mid).max(0.0);
909            exact_g3 += x3 * n_full * dx;
910            exact_i4 += x3 * x_half * n_full * (1.0 + n_full) * dx;
911        }
912        assert!(
913            max_dn.is_finite(),
914            "NaN/Inf detected in delta_n at z={}",
915            z_eval
916        );
917
918        let delta_rho_eq = if max_dn > 1e-15 {
919            // Use exact computation when the distortion is large enough that
920            // the perturbative expansion (which drops Δn² in I₄ and linearizes
921            // 1/(G₃+ΔG₃)) becomes inaccurate. The exact computation has ~0.1%
922            // discretization error from computing I₄/(4G₃), which is negligible
923            // for |ΔG₃/G₃| > 0.1 but catastrophic for tiny distortions (~10⁻⁵).
924            if delta_g3.abs() / G3_PLANCK > 0.1 && exact_g3 > 1e-30 {
925                exact_i4 / (4.0 * exact_g3) - 1.0
926            } else {
927                delta_i4 / (4.0 * G3_PLANCK) - delta_g3 / G3_PLANCK
928            }
929        } else {
930            0.0
931        };
932
933        let x_e = self.x_e_at(z_eval);
934        let t_c = self.cosmo.t_compton(z_eval, x_e);
935        let theta_z_val = self.cosmo.theta_z(z_eval);
936
937        let delta_rho_inj = if let Some(ref inj) = self.injection {
938            let q_rel = inj.heating_rate(z_eval, &self.cosmo);
939            q_rel * t_c / (4.0 * theta_z_val)
940        } else {
941            0.0
942        };
943
944        self.rho_eq = 1.0 + delta_rho_eq;
945
946        // Compute Compton optical depth for this step
947        let hubble = self.cosmo.hubble(z_eval);
948        let dtau = if t_c > 0.0 && hubble > 0.0 {
949            actual_dz / (hubble * (1.0 + z_eval) * t_c)
950        } else {
951            0.0
952        };
953
954        if theta_z_val > 1e-8 {
955            let n_h = self.cosmo.n_h(z_eval);
956            let n_he = self.cosmo.f_he() * n_h;
957            let n_e = x_e * n_h;
958
959            // Compton coupling coefficient R = (8/3)(ρ̃_γ/α_h) and adiabatic rate H·t_C.
960            //
961            // The T_e ODE in Thomson optical depth τ (Seager+ 1999, Chluba+ 2012):
962            //   dρ_e/dτ = R·[(ρ_eq + δρ_inj − H_dcbr) − ρ_e] − H·t_C·ρ_e
963            //
964            // R = 8ρ_γ/(3 m_e c² n_total) = (8/3)(ρ̃_γ/α_h) is the Compton
965            // coupling rate per unit Thomson τ. All terms that act through Compton
966            // (equilibrium, injection, DC/BR) carry this factor. The adiabatic
967            // cooling H·t_C·ρ_e is independent of the photon field.
968            let alpha_h_ratio = (n_e + n_h + n_he) / n_e;
969            let rho_gamma_per_e = KAPPA_GAMMA * theta_z_val.powi(4) * G3_PLANCK / n_e;
970            let r_compton = (8.0 / 3.0) * rho_gamma_per_e / alpha_h_ratio;
971            let lambda_htc = hubble * t_c;
972
973            // DC/BR back-reaction on T_e. The heating integral of a pure
974            // Planck spectrum vanishes identically (the (e^x − 1) Bose factor
975            // cancels 1/n_pl), so for tiny Δn we skip it as a performance
976            // optimisation. Fire on any non-trivial Δn regardless of whether
977            // a scenario is set — custom initial conditions should still feel
978            // DC/BR heating.
979            let needs_dcbr_heating = max_dn > 1e-20;
980            let (h_dc_br, dh_drho) = if needs_dcbr_heating && !self.disable_dcbr {
981                let theta_e_val = self.electron_temp.rho_e * theta_z_val;
982                let t_rad = theta_z_val * crate::constants::M_E_C2 / crate::constants::K_BOLTZMANN;
983                let z_approx = (t_rad / self.cosmo.t_cmb - 1.0).max(0.0);
984                let y_he_ii = crate::recombination::saha_he_ii(z_approx, &self.cosmo);
985                let y_he_i = crate::recombination::saha_he_i(z_approx, &self.cosmo);
986                let compute_fd = theta_z_val > 2e-5;
987                dcbr_heating_with_derivative(
988                    &self.grid.x,
989                    &self.delta_n,
990                    theta_z_val,
991                    theta_e_val,
992                    n_h,
993                    n_he,
994                    n_e,
995                    x_e,
996                    y_he_ii,
997                    y_he_i,
998                    compute_fd,
999                    &self.ln_x_half,
1000                    &self.dc_suppression_half,
1001                )
1002            } else {
1003                (0.0, 0.0)
1004            };
1005
1006            // Backward Euler integration of the full T_e ODE:
1007            //
1008            //   dρ_e/dτ = R·[(ρ_eq + δρ_inj − H_dcbr) − ρ_e] − H·t_C·ρ_e
1009            //
1010            // Linearizing H_dcbr around ρ_e^n and solving implicitly:
1011            //   ρ_e^{n+1} = [ρ_e^n + Δτ·R·(ρ_eq + δρ_inj − H₀ + H'·ρ_e^n)]
1012            //                / [1 + Δτ·(R·(1 + H') + H·t_C)]
1013            //
1014            // When R·Δτ ≫ 1 (pre-recombination): quasi-stationary
1015            //   ρ_e ≈ ρ_eq + δρ_inj (same as before — R cancels).
1016            // When R·Δτ ≪ 1, H·t_C·Δτ = dz/(1+z) ~ O(1) (post-recombination):
1017            //   ρ_e cools adiabatically as T_m ∝ (1+z)², with residual Compton
1018            //   heating from X_e ~ 10⁻⁴.
1019            let rho_e_old = self.electron_temp.rho_e;
1020
1021            // Store unscaled source = ρ_eq + δρ_inj; R applied at point of use.
1022            let rho_source = self.rho_eq + delta_rho_inj;
1023
1024            // Cache ODE coefficients for the bordered Newton solver.
1025            self.rho_e_ode_cache = Some(RhoECache {
1026                rho_e_old,
1027                r_compton,
1028                rho_source,
1029                lambda_htc,
1030                dh_drho,
1031                dtau,
1032            });
1033
1034            let numerator =
1035                rho_e_old + dtau * r_compton * (rho_source - h_dc_br + dh_drho * rho_e_old);
1036            let denominator = 1.0 + dtau * (r_compton * (1.0 + dh_drho) + lambda_htc);
1037            let rho_e_raw = numerator / denominator;
1038            // BE (non-coupled) path: tight clamp at 1.5. Post-recombination
1039            // ρ_e can drop well below 1 (T_m ∝ (1+z)²); the upper bound
1040            // prevents unphysical overshoot of the perturbative step in weak-
1041            // Compton regimes. The bordered-Newton path (coupled mode) uses
1042            // a looser [0, 3] bound because bursts at high z can legitimately
1043            // push ρ_e above 1. Attempted M1 unification to a single range
1044            // degrades post-recombination accuracy and is rejected.
1045            //
1046            // NaN/∞ are rejected explicitly (audit H1): f64::NaN.clamp(a, b) ==
1047            // NaN and NaN comparisons return false, so a naïve clamp would
1048            // silently propagate a degenerate result into later solves.
1049            if !rho_e_raw.is_finite() {
1050                self.diag.rho_e_clamped += 1;
1051                // Keep the prior ρ_e rather than poisoning the solver state.
1052            } else {
1053                let rho_e_new = rho_e_raw.clamp(0.0, 1.5);
1054                if rho_e_new != rho_e_raw {
1055                    self.diag.rho_e_clamped += 1;
1056                }
1057                self.electron_temp.rho_e = rho_e_new;
1058            }
1059        } else {
1060            // θ_z too small for meaningful Compton physics.
1061            self.rho_e_ode_cache = None;
1062        }
1063
1064        (x_e, t_c, theta_z_val, max_dn, dtau, hubble)
1065    }
1066
1067    /// Subtract the temperature shift component from Δn to enforce
1068    /// photon number conservation: ∫x² Δn dx = 0.
1069    ///
1070    /// DC/BR creates photons at low x that accumulate as a temperature
1071    /// shift in Δn. This growing T-shift feeds back: ρ_e rises → DC/BR
1072    /// equilibrium target rises → more photon creation → larger T-shift.
1073    /// Subtracting the T-shift breaks this positive feedback loop.
1074    ///
1075    /// Algorithm:
1076    ///   1. ΔG₂ = ∫x² Δn dx  (photon number perturbation)
1077    ///   2. δT = ΔG₂ / (3 × G₂)  (temperature shift)
1078    ///   3. Δn[i] -= δT × g_bb(x[i])  (subtract T-shift component)
1079    ///   4. accumulated_δT += δT
1080    /// Subtract the number-conserving temperature shift from Δn.
1081    /// Returns the δT/T that was subtracted.
1082    fn subtract_temperature_shift(&mut self) -> f64 {
1083        // Compute ΔG₂ = ∫x² Δn dx using precomputed per-node quadrature weights.
1084        // Equivalent to midpoint trapezoidal rule but avoids recomputing x_half² × dx.
1085        let mut delta_g2 = 0.0;
1086        for i in 0..self.grid.n {
1087            delta_g2 += self.quad_weights_x2[i] * self.delta_n[i];
1088        }
1089
1090        // δT/T = ΔG₂ / ∫x²·g_bb·dx
1091        // A temperature shift Δn = δT × g_bb(x) has ΔG₂ = δT × ∫x²·g_bb·dx.
1092        // Using the discrete quadrature g2_gbb_discrete (same trapezoidal rule as
1093        // the number integral above) ensures exact photon number conservation on
1094        // the discrete grid, avoiding O(N⁻²) drift from analytical 3·G₂.
1095        let delta_t = delta_g2 / self.g2_gbb_discrete;
1096
1097        // Subtract the temperature shift from Δn
1098        for i in 0..self.grid.n {
1099            self.delta_n[i] -= delta_t * self.g_bb_grid[i];
1100        }
1101
1102        self.accumulated_delta_t += delta_t;
1103        delta_t
1104    }
1105
1106    /// Advance the solver by a single adaptively-chosen timestep.
1107    ///
1108    /// Returns the `dz` taken. Most users should call [`Self::run`] or
1109    /// [`Self::run_with_snapshots`] instead of stepping manually.
1110    pub fn step(&mut self) -> f64 {
1111        let dz = self.adaptive_dz();
1112        self.step_with_dz(dz)
1113    }
1114
1115    /// Take a single timestep with a specified dz (instead of the adaptive choice).
1116    /// Used by `run_with_snapshots` to land exactly on requested snapshot redshifts.
1117    fn step_with_dz(&mut self, dz: f64) -> f64 {
1118        let z_new = (self.z - dz).max(self.config.z_end);
1119        let actual_dz = self.z - z_new;
1120        let z_mid = self.z - 0.5 * actual_dz;
1121
1122        // update_temperatures computes ρ_e via backward Euler and returns dtau + hubble
1123        let (x_e, _t_c, theta_z_val, max_dn_abs, dtau, h) =
1124            self.update_temperatures(z_mid, actual_dz);
1125
1126        let n_h = self.cosmo.n_h(z_mid);
1127        let n_he = self.cosmo.f_he() * n_h;
1128        let n_e = x_e * n_h;
1129
1130        let has_phot_src = self
1131            .injection
1132            .as_ref()
1133            .map_or(false, |inj| inj.has_photon_source());
1134
1135        let rho_e = self.electron_temp.rho_e;
1136
1137        let theta_e_full = theta_z_val * rho_e;
1138        let n = self.grid.n;
1139
1140        // Skip DC/BR computation at very low redshift where rates are negligible.
1141        // θ_z < 1e-8 corresponds to z ~ 22. For soft photon distortions (x ~ 1e-3),
1142        // BR absorption has τ > 1 down to z ~ 1700, so the threshold must be low
1143        // enough to capture post-recombination absorption.
1144        let skip_dcbr = self.disable_dcbr || theta_z_val < 1e-8;
1145
1146        if !skip_dcbr {
1147            // Cache He ionization fractions once per step (same z for all grid points)
1148            let t_rad = theta_z_val * crate::constants::M_E_C2 / crate::constants::K_BOLTZMANN;
1149            let z_approx = (t_rad / self.cosmo.t_cmb - 1.0).max(0.0);
1150            let y_he_ii = crate::recombination::saha_he_ii(z_approx, &self.cosmo);
1151            let y_he_i = crate::recombination::saha_he_i(z_approx, &self.cosmo);
1152
1153            // Save old DC/BR buffers for CN DC/BR option
1154            if self.config.cn_dcbr {
1155                self.komp_ws.dcbr_em_old[..n].copy_from_slice(&self.emission_rates[..n]);
1156                self.komp_ws.dcbr_neq_old[..n].copy_from_slice(&self.n_eq_minus_n_pl[..n]);
1157            }
1158
1159            // Precompute DC/BR rates for the coupled solve (reuse pre-allocated buffers)
1160            // Hoist x-independent prefactors out of the grid loop
1161            let dc_pre = dc_prefactor(theta_z_val);
1162            let br_pre = br_precompute(
1163                theta_e_full,
1164                theta_z_val,
1165                n_h,
1166                n_he,
1167                n_e,
1168                x_e,
1169                y_he_ii,
1170                y_he_i,
1171            );
1172            // DC/BR equilibrium: Planck at T_e (Chluba & Sunyaev 2012, Eq. 8).
1173            //
1174            // The backward Euler for ρ_e gives ρ_e ≈ ρ_eq + δρ_inj (quasi-stationary).
1175            // Using the full ρ_e for DC/BR would double-count injection energy
1176            // (Kompaneets already injects via φ = 1/ρ_e). We subtract the
1177            // injection contribution:
1178            //   ρ_dcbr = ρ_e − R·δρ_inj × dτ/(1 + dτ(R(1+h') + H·t_C))
1179            //
1180            // For zero injection: ρ_dcbr = ρ_e (correct T_e target).
1181            // For injection: removes the δρ_inj bump, avoiding double-counting.
1182            let rho_eq_dcbr = if let Some(c) = self.rho_e_ode_cache {
1183                let delta_rho_inj = c.rho_source - self.rho_eq;
1184                let denom = 1.0 + c.dtau * (c.r_compton * (1.0 + c.dh_drho) + c.lambda_htc);
1185                let inj_contribution = if denom.abs() > 1e-30 {
1186                    c.r_compton * delta_rho_inj * c.dtau / denom
1187                } else {
1188                    0.0
1189                };
1190                (rho_e - inj_contribution).clamp(0.5, 2.0)
1191            } else {
1192                rho_e
1193            };
1194            let delta_rho = rho_eq_dcbr - 1.0;
1195            let inv_rho_eq = 1.0 / rho_eq_dcbr;
1196
1197            // Hoist struct-field slices and scalars so LLVM can register-allocate
1198            // them and elide bounds checks inside the grid loop. Splitting on
1199            // `in_taylor` specialises the hot |δρ|<0.01 path into a straight-line
1200            // loop with no per-point branch on `delta_rho`.
1201            let dcbr_scale = self.dcbr_scale;
1202            let in_taylor = delta_rho.abs() < 0.01;
1203            let mut any_nan = false;
1204            let mut max_rate = self.diag.max_emission_rate;
1205
1206            // Borrow the read-only and mut slices once, up-front. Disjoint
1207            // fields → the borrow checker accepts this.
1208            let xs: &[f64] = &self.grid.x[..n];
1209            let dc_supp: &[f64] = &self.dc_suppression_grid[..n];
1210            let ln_x: &[f64] = &self.ln_x_grid[..n];
1211            let exp_m1: &[f64] = &self.exp_m1_grid[..n];
1212            let exp_x: &[f64] = &self.exp_grid[..n];
1213            let planck_g: &[f64] = &self.planck_grid[..n];
1214            let em_out: &mut [f64] = &mut self.emission_rates[..n];
1215            let neq_out: &mut [f64] = &mut self.n_eq_minus_n_pl[..n];
1216            let dem_out: &mut [f64] = &mut self.dem_drho_eq[..n];
1217            let dneq_out: &mut [f64] = &mut self.dneq_drho_eq[..n];
1218
1219            if in_taylor {
1220                // Taylor expansion of the Bose factor exp(x/ρ) − 1 around ρ=1.
1221                //   exp(x/ρ) − 1 = exp_m1(x) + [exp(x/ρ) − exp(x)]
1222                //               = exp_m1(x) − x · exp(x) · (ρ−1)/ρ + O((ρ−1)²)
1223                // so  bose_factor ≈ exp_m1(x) − x · δρ_inv · exp(x),
1224                //       δρ_inv ≡ (ρ−1)/ρ.
1225                // Valid for |ρ−1| < 0.01 — this branch replaces the direct
1226                // evaluation exp(x/ρ)−1 that near-cancels for small (ρ−1).
1227                // Analytic ρ-derivatives of em/neq below use the same expansion.
1228                let delta_rho_inv = delta_rho * inv_rho_eq;
1229                let inv_rho_eq2 = inv_rho_eq * inv_rho_eq;
1230                for i in 0..n {
1231                    let xi = xs[i];
1232                    let inv_xi3 = 1.0 / (xi * xi * xi);
1233
1234                    let k_dc = dc_pre * dc_supp[i];
1235                    let k_br = match br_pre {
1236                        Some(ref pre) => br_emission_coefficient_fast_preln(xi, ln_x[i], pre),
1237                        None => 0.0,
1238                    };
1239                    let k_sum = k_dc + k_br;
1240
1241                    let exp_xi = exp_x[i];
1242                    let bose_factor = exp_m1[i] - xi * delta_rho_inv * exp_xi;
1243
1244                    let uncapped = dcbr_scale * k_sum * bose_factor * inv_xi3;
1245                    let finite = uncapped.is_finite();
1246                    any_nan |= uncapped.is_nan();
1247                    if finite && uncapped > max_rate {
1248                        max_rate = uncapped;
1249                    }
1250                    em_out[i] = if finite { uncapped } else { 0.0 };
1251
1252                    let npl = planck_g[i];
1253                    let npl_1p = npl * (npl + 1.0);
1254                    neq_out[i] = xi * delta_rho_inv * npl_1p;
1255
1256                    // Analytic ρ-derivatives (Taylor regime only). Outside the
1257                    // Taylor window we zero them, reproducing the legacy
1258                    // Picard-in-ρ_e behaviour the tests were validated against —
1259                    // the bordered Newton c-vector would otherwise be poisoned
1260                    // by exp(x/ρ_eq) growing across many decades.
1261                    let dbose_drho = -xi * exp_xi * inv_rho_eq2;
1262                    let dem_raw = dcbr_scale * k_sum * dbose_drho * inv_xi3;
1263                    dem_out[i] = if dem_raw.is_finite() { dem_raw } else { 0.0 };
1264                    let dneq_raw = npl_1p * xi * inv_rho_eq2;
1265                    dneq_out[i] = if dneq_raw.is_finite() { dneq_raw } else { 0.0 };
1266                }
1267            } else {
1268                // Full (non-Taylor) path — post-recombination regime where
1269                // ρ drifts to O(0.3). ρ-derivatives zeroed (see Taylor branch).
1270                for i in 0..n {
1271                    let xi = xs[i];
1272                    let inv_xi3 = 1.0 / (xi * xi * xi);
1273
1274                    let k_dc = dc_pre * dc_supp[i];
1275                    let k_br = match br_pre {
1276                        Some(ref pre) => br_emission_coefficient_fast_preln(xi, ln_x[i], pre),
1277                        None => 0.0,
1278                    };
1279
1280                    let xe = xi * inv_rho_eq;
1281                    let bose_factor = if xe > 500.0 {
1282                        f64::INFINITY
1283                    } else {
1284                        xe.exp_m1()
1285                    };
1286                    let uncapped = dcbr_scale * (k_dc + k_br) * bose_factor * inv_xi3;
1287                    let finite = uncapped.is_finite();
1288                    any_nan |= uncapped.is_nan();
1289                    if finite && uncapped > max_rate {
1290                        max_rate = uncapped;
1291                    }
1292                    em_out[i] = if finite { uncapped } else { 0.0 };
1293
1294                    let npl = planck_g[i];
1295                    neq_out[i] = planck(xe) - npl;
1296
1297                    dem_out[i] = 0.0;
1298                    dneq_out[i] = 0.0;
1299                }
1300            }
1301
1302            if any_nan {
1303                self.diag.nan_emission_detected = true;
1304            }
1305            self.diag.max_emission_rate = max_rate;
1306        }
1307
1308        // Fill photon source buffer (simple — no split-source logic needed)
1309        let source_active;
1310        if has_phot_src {
1311            let dt = actual_dz / (h * (1.0 + z_mid));
1312            if let Some(ref inj) = self.injection {
1313                for i in 0..n {
1314                    self.photon_source_buf[i] =
1315                        inj.photon_source_rate(self.grid.x[i], z_mid, &self.cosmo) * dt;
1316                }
1317            }
1318            // Use a threshold that excludes Gaussian tails > ~8σ from peak.
1319            // Peak source ~ O(1e-2), so 1e-20 catches everything within ~6σ
1320            // but correctly disables the bordered system in the far tails.
1321            source_active = self.photon_source_buf.iter().any(|&v| v.abs() > 1e-20);
1322        } else {
1323            for v in self.photon_source_buf.iter_mut() {
1324                *v = 0.0;
1325            }
1326            source_active = false;
1327        }
1328
1329        // Photon source routing:
1330        //   - When coupled DC/BR Newton runs (below), the integrated source
1331        //     is passed through `DcbrCoupling::photon_source` so it enters
1332        //     the Newton residual directly. This keeps the CN "old" Kompaneets
1333        //     flux computed from the un-injected Δn_old, and lets DC/BR
1334        //     absorption act on the source within the same step.
1335        //   - When `skip_dcbr` is true, no Newton runs and the source is
1336        //     added via the explicit fallback further below. Nothing to do
1337        //     here in that case.
1338        //
1339        // We deliberately do NOT pre-add `photon_source_buf` into `delta_n`
1340        // here (legacy pre-add path). That approach was mathematically
1341        // equivalent except for the Kompaneets-CN "old" flux, which would
1342        // then reflect a state where the source had already been injected
1343        // at t_old — an O(dt · ∂K/∂t) distortion of the integrated injection.
1344
1345        // Build ρ_e coupling for the bordered Newton system.
1346        // When active, ρ_e is solved simultaneously with Δn inside the Newton
1347        // iteration — no Picard outer loop needed. DC/BR spike absorption
1348        // immediately feeds back into ρ_e within the same Newton step.
1349        let rho_coupling = if !skip_dcbr && dtau > 0.0 {
1350            self.rho_e_ode_cache
1351                .map(|c| crate::kompaneets::RhoECoupling {
1352                    rho_e_old: c.rho_e_old,
1353                    r_compton: c.r_compton,
1354                    rho_source: c.rho_source,
1355                    lambda_exp: c.lambda_htc,
1356                    dh_drho: c.dh_drho,
1357                    h_norm: 1.0 / (4.0 * G3_PLANCK * theta_z_val),
1358                })
1359        } else {
1360            None
1361        };
1362
1363        // Coupled Kompaneets + DC/BR + ρ_e Newton step.
1364        {
1365            let dcbr_coupling = if !skip_dcbr && self.coupled_dcbr {
1366                Some(crate::kompaneets::DcbrCoupling {
1367                    emission_rates: &self.emission_rates,
1368                    n_eq_minus_n_pl: &self.n_eq_minus_n_pl,
1369                    dem_drho_eq: &self.dem_drho_eq,
1370                    dneq_drho_eq: &self.dneq_drho_eq,
1371                    photon_source: if source_active && dtau > 0.0 {
1372                        Some(&self.photon_source_buf)
1373                    } else {
1374                        None
1375                    },
1376                    cn_dcbr: self.config.cn_dcbr,
1377                })
1378            } else {
1379                None
1380            };
1381
1382            let (newton_converged, rho_e_out, newton_last_correction) =
1383                kompaneets_step_coupled_inplace(
1384                    &self.grid,
1385                    &mut self.delta_n,
1386                    theta_e_full,
1387                    theta_z_val,
1388                    dtau,
1389                    dcbr_coupling.as_ref(),
1390                    rho_coupling.as_ref(),
1391                    &mut self.komp_ws,
1392                    max_dn_abs,
1393                    self.config.max_newton_iter,
1394                );
1395            if !newton_converged {
1396                self.diag.newton_exhausted += 1;
1397                // Report the first exhaustion immediately, then at 10, 100,
1398                // 1000, ... so a persistently-failing solver surfaces the
1399                // problem instead of hiding behind a single stale warning.
1400                let n = self.diag.newton_exhausted;
1401                let report = n == 1 || (n >= 10 && n.is_power_of_two()) || n % 1000 == 0;
1402                if report {
1403                    let tol = 1e-8 * max_dn_abs + 1e-14;
1404                    self.diag.warnings.push(format!(
1405                        "Newton iteration did not converge at z={:.4e} \
1406                         after {} iterations (step {}, exhaustion #{}). \
1407                         Last correction |δx|={:.4e}, tol={:.4e}. \
1408                         Consider increasing max_newton_iter or reducing dtau_max.",
1409                        self.z,
1410                        self.config.max_newton_iter,
1411                        self.step_count,
1412                        n,
1413                        newton_last_correction,
1414                        tol
1415                    ));
1416                }
1417            }
1418
1419            // Update ρ_e from the coupled solve.
1420            // Reject NaN/∞ explicitly (see audit H1): the bordered-system
1421            // denominator can go through zero and emit NaN, which the naïve
1422            // clamp would silently propagate into subsequent steps.
1423            if rho_coupling.is_some() {
1424                if !rho_e_out.is_finite() {
1425                    self.diag.rho_e_clamped += 1;
1426                    // Keep the prior ρ_e.
1427                } else {
1428                    let rho_clamped = rho_e_out.clamp(0.0, 3.0);
1429                    if rho_clamped != rho_e_out {
1430                        self.diag.rho_e_clamped += 1;
1431                    }
1432                    self.electron_temp.rho_e = rho_clamped;
1433                }
1434            }
1435
1436            // DC/BR operator-split step (when not using coupled mode)
1437            if !skip_dcbr && !self.coupled_dcbr {
1438                for i in 1..n - 1 {
1439                    let em = self.emission_rates[i];
1440                    let neq = self.n_eq_minus_n_pl[i];
1441                    let decay = (-dtau * em).exp();
1442                    self.delta_n[i] = neq + (self.delta_n[i] - neq) * decay;
1443                }
1444            }
1445        }
1446
1447        // Fallback pre-add for the paths where the photon source was NOT
1448        // plumbed through the Newton residual. Two cases:
1449        //   - `skip_dcbr`: no Newton runs (low-z or user-disabled DC/BR).
1450        //   - `!coupled_dcbr`: operator-split DC/BR runs after a Newton that
1451        //     did not receive the source through `DcbrCoupling::photon_source`.
1452        // In both cases we fall back to adding S·dt directly to Δn, which is
1453        // a forward-Euler-in-source treatment — acceptable because these
1454        // paths are diagnostic / post-recombination anyway.
1455        let source_via_newton = !skip_dcbr && self.coupled_dcbr;
1456        if let Some(ref inj) = self.injection {
1457            if inj.has_photon_source() && !source_via_newton {
1458                let dt = actual_dz / (h * (1.0 + z_mid));
1459                for i in 0..n {
1460                    let source = inj.photon_source_rate(self.grid.x[i], z_mid, &self.cosmo);
1461                    if source.abs() > 1e-50 {
1462                        self.delta_n[i] += source * dt;
1463                    }
1464                }
1465            }
1466        }
1467
1468        // Number-conserving mode: subtract temperature shift G_bb from Δn.
1469        // This prevents DC/BR-created low-x photons from accumulating as a
1470        // secular T-shift that masks the physical distortion shape.
1471        if self.number_conserving
1472            && self.z > self.config.nc_z_min
1473            && (self.nc_stride <= 1 || self.step_count % self.nc_stride == 0)
1474        {
1475            self.subtract_temperature_shift();
1476        }
1477
1478        self.z = z_new;
1479        self.step_count += 1;
1480        actual_dz
1481    }
1482
1483    /// Integrate from `z_start` to `z_end`, recording a snapshot at each
1484    /// requested redshift.
1485    ///
1486    /// `snapshot_redshifts` may be given in any order; they are sorted
1487    /// internally. Redshifts above `z_start` or below `z_end` are clamped
1488    /// to the initial and final state respectively. The returned slice
1489    /// borrows from `self.snapshots`; for an owned result, use
1490    /// [`Self::run_to_result`].
1491    pub fn run_with_snapshots(&mut self, snapshot_redshifts: &[f64]) -> &[SolverSnapshot] {
1492        let initial_dn = self.initial_delta_n.take();
1493        let injection = self.injection.take();
1494        // Preserve user-set configuration across the internal reset: reset()
1495        // now clears all flags to their defaults to prevent stale toggles
1496        // leaking between back-to-back runs, but a single run_with_snapshots
1497        // call must honour whatever the caller configured via the builder or
1498        // direct field assignment.
1499        let saved_config = self.config.clone();
1500        let saved_warnings = std::mem::take(&mut self.diag.warnings);
1501        let saved_disable_dcbr = self.disable_dcbr;
1502        let saved_coupled_dcbr = self.coupled_dcbr;
1503        let saved_number_conserving = self.number_conserving;
1504        let saved_nc_stride = self.nc_stride;
1505        let saved_dcbr_scale = self.dcbr_scale;
1506        self.reset();
1507        self.config = saved_config;
1508        self.z = self.config.z_start;
1509        self.diag.warnings = saved_warnings;
1510        self.disable_dcbr = saved_disable_dcbr;
1511        self.coupled_dcbr = saved_coupled_dcbr;
1512        self.number_conserving = saved_number_conserving;
1513        self.nc_stride = saved_nc_stride;
1514        self.dcbr_scale = saved_dcbr_scale;
1515        self.injection = injection;
1516
1517        // Frequency grid sanity: the μ/y decomposition silently returns
1518        // zeros when fewer than three points fall in [0.5, 18]. Surface
1519        // that case as a warning so users with a custom narrow grid see
1520        // a real signal instead of a flat-zero result.
1521        let band_count = crate::distortion::decomposition_band_count(&self.grid.x);
1522        if band_count < 3 {
1523            self.diag.warnings.push(format!(
1524                "Frequency grid has only {band_count} point(s) in the μ/y decomposition band \
1525                 [0.5, 18]. The decomposition will silently return μ=y=0; widen x_min/x_max \
1526                 or increase n_points to avoid this."
1527            ));
1528        }
1529
1530        // DarkPhotonResonance installs its IC at z_start; warn users whose
1531        // z_start doesn't match the NWA resonance redshift (the builder
1532        // auto-corrects, but `set_config` on a bare solver bypasses that).
1533        let dp_z_res = self
1534            .injection
1535            .as_ref()
1536            .and_then(|inj| inj.dark_photon_params(&self.cosmo))
1537            .map(|(_g, z_res)| z_res);
1538        if let Some(z_res) = dp_z_res {
1539            if (self.config.z_start - z_res).abs() > 1e-6 * z_res {
1540                self.diag.warnings.push(format!(
1541                    "DarkPhotonResonance: z_start={:.3e} ≠ NWA resonance z_res={:.3e}; the \
1542                     depletion IC is installed at z_start, not z_res. Prefer SolverBuilder, \
1543                     which sets z_start = z_res automatically.",
1544                    self.config.z_start, z_res
1545                ));
1546            }
1547        }
1548
1549        // Precedence: an explicit user-set Δn(x) via `set_initial_delta_n`
1550        // wins. Otherwise, if the injection scenario supplies its own IC
1551        // (e.g. DarkPhotonResonance), install that.
1552        if let Some(dn) = initial_dn {
1553            self.delta_n = dn;
1554        } else if let Some(ref inj) = self.injection {
1555            if let Some(dn) = inj.initial_delta_n(&self.grid.x, &self.cosmo) {
1556                assert_eq!(
1557                    dn.len(),
1558                    self.grid.n,
1559                    "injection initial_delta_n length {} != grid size {}",
1560                    dn.len(),
1561                    self.grid.n
1562                );
1563                self.delta_n = dn;
1564            }
1565        }
1566
1567        // Sort snapshot redshifts descending (we integrate from high z to low z)
1568        let mut sorted_z: Vec<f64> = snapshot_redshifts.to_vec();
1569        sorted_z.sort_by(|a, b| b.total_cmp(a));
1570        let mut next_snap = 0;
1571
1572        // Skip any snapshot redshifts at or above z_start (save initial state for them)
1573        while next_snap < sorted_z.len() && sorted_z[next_snap] >= self.z {
1574            self.save_snapshot_at(sorted_z[next_snap]);
1575            next_snap += 1;
1576        }
1577
1578        let step_limit = 5_000_000;
1579        while self.z > self.config.z_end && self.step_count < step_limit {
1580            // Check if the next snapshot is between current z and what the step
1581            // would produce — if so, take a partial step to land exactly on it
1582            if next_snap < sorted_z.len() {
1583                let snap_z = sorted_z[next_snap];
1584                if snap_z >= self.config.z_end && snap_z < self.z {
1585                    let dz_to_snap = self.z - snap_z;
1586                    let dz_natural = self.adaptive_dz();
1587                    if dz_natural >= dz_to_snap {
1588                        // Would overshoot the snapshot — take exact step to land on it
1589                        self.step_with_dz(dz_to_snap);
1590                        // Save snapshots for all requested redshifts at this z
1591                        while next_snap < sorted_z.len() && self.z <= sorted_z[next_snap] {
1592                            self.save_snapshot_at(sorted_z[next_snap]);
1593                            next_snap += 1;
1594                        }
1595                        continue;
1596                    }
1597                }
1598            }
1599
1600            self.step();
1601
1602            // Save snapshots for any requested redshifts we've reached or passed
1603            while next_snap < sorted_z.len() && self.z <= sorted_z[next_snap] {
1604                self.save_snapshot_at(sorted_z[next_snap]);
1605                next_snap += 1;
1606            }
1607
1608            if self.step_count % 100000 == 0 {
1609                self.diag.warnings.push(format!(
1610                    "Progress: z={:.1e} step={} ρ_e={:.6} ρ_eq={:.6}",
1611                    self.z, self.step_count, self.electron_temp.rho_e, self.rho_eq
1612                ));
1613            }
1614        }
1615
1616        if self.step_count >= step_limit {
1617            self.diag.warnings.push(format!(
1618                "Step limit ({}) reached at z={:.4e}. \
1619                 Run may be incomplete. Consider increasing dtau_max or narrowing the z range.",
1620                step_limit, self.z
1621            ));
1622        }
1623
1624        // Fill remaining snapshots below z_end with final state
1625        while next_snap < sorted_z.len() {
1626            self.save_snapshot();
1627            next_snap += 1;
1628        }
1629
1630        // End-of-run diagnostics: ρ_e clamp counter. Individual clamps are
1631        // silent (only logged in diag), but a large count indicates that the
1632        // implicit T_e solve is regularly hitting the [0, 1.5] / [0, 3] bounds
1633        // — the clamped steps silently discard part of the injection energy
1634        // and shouldn't be ignored.
1635        if self.diag.rho_e_clamped > 10 {
1636            self.diag.warnings.push(format!(
1637                "ρ_e was clamped {} times during the run. Clamping discards injection \
1638                 energy from the T_e ODE silently; repeated hits suggest the injection \
1639                 amplitude is outside the linearized regime or dtau_max is too large \
1640                 for the source sharpness.",
1641                self.diag.rho_e_clamped
1642            ));
1643        }
1644        if self.diag.nan_emission_detected {
1645            self.diag.warnings.push(
1646                "NaN emission rate detected during the run. DC/BR rates were capped \
1647                 to keep the solver alive but the final Δn may be polluted; narrow \
1648                 x_min or reduce dtau_max and rerun."
1649                    .to_string(),
1650            );
1651        }
1652
1653        &self.snapshots
1654    }
1655
1656    /// Run the solver with `n_snapshots` log-spaced snapshot redshifts between
1657    /// z_start and z_end. Note: with `n_snapshots=1` the single snapshot is at
1658    /// z_start (the first log-spaced point), not z_end.
1659    pub fn run(&mut self, n_snapshots: usize) -> &[SolverSnapshot] {
1660        let log_s = self.config.z_start.ln();
1661        let log_e = self.config.z_end.ln();
1662        let zs: Vec<f64> = (0..n_snapshots)
1663            .map(|i| (log_s + (log_e - log_s) * i as f64 / (n_snapshots - 1).max(1) as f64).exp())
1664            .collect();
1665        self.run_with_snapshots(&zs)
1666    }
1667
1668    fn save_snapshot(&mut self) {
1669        self.save_snapshot_at(self.z);
1670    }
1671
1672    /// Save a snapshot with a specific redshift label.
1673    /// The solver state (delta_n, rho_e, etc.) is taken from the current state,
1674    /// but the snapshot's z field is set to the requested value.
1675    fn save_snapshot_at(&mut self, z: f64) {
1676        // Extract μ, y via the Bianchini & Fabbian (2022) nonlinear BE fit
1677        // on the T-shift-subtracted Δn (the fit recovers the accumulated
1678        // temperature shift separately through its δ parameter, so μ and y
1679        // are unaffected by whether the subtracted G_bb is added back).
1680        let (mu, y) = self.extract_mu_y_joint();
1681        let drho_spectrum = crate::spectrum::delta_rho_over_rho(&self.grid.x, &self.delta_n);
1682        // Total energy = energy in the spectral Δn + energy in the subtracted
1683        // T-shift. Δρ/ρ = 4δT/T for a temperature shift (Stefan-Boltzmann).
1684        let drho = drho_spectrum + 4.0 * self.accumulated_delta_t;
1685
1686        // Reconstruct the full Δn by adding the accumulated T-shift back.
1687        // The NC mode subtracts the T-shift during evolution to prevent
1688        // DC/BR over-thermalization feedback, but the output should contain
1689        // the full spectral distortion (distortion + T-shift) for comparison
1690        // with CosmoTherm and other codes.
1691        let full_delta_n = if self.number_conserving && self.accumulated_delta_t.abs() > 1e-30 {
1692            let mut dn = self.delta_n.clone();
1693            for i in 0..self.grid.n {
1694                dn[i] += self.accumulated_delta_t * self.g_bb_grid[i];
1695            }
1696            dn
1697        } else {
1698            self.delta_n.clone()
1699        };
1700
1701        self.snapshots.push(SolverSnapshot {
1702            z,
1703            delta_n: full_delta_n,
1704            rho_e: self.electron_temp.rho_e,
1705            mu,
1706            y,
1707            delta_rho_over_rho: drho,
1708            accumulated_delta_t: self.accumulated_delta_t,
1709        });
1710    }
1711
1712    /// Extract `(μ, y)` from the current `Δn(x)` via the default joint
1713    /// least-squares decomposition (B&F 2022 nonlinear BE; see
1714    /// [`crate::distortion::decompose_distortion`]).
1715    pub fn extract_mu_y_joint(&self) -> (f64, f64) {
1716        let params = crate::distortion::decompose_distortion(&self.grid.x, &self.delta_n);
1717        (params.mu, params.y)
1718    }
1719
1720    /// Run the solver and return an owned [`crate::output::SolverResult`]
1721    /// with a single snapshot at `z_obs`.
1722    ///
1723    /// This is the preferred entry point: the result does not borrow the
1724    /// solver and has built-in JSON/CSV/table serialization. For runs that
1725    /// need multiple intermediate snapshots, call
1726    /// [`Self::run_with_snapshots`] directly and inspect
1727    /// [`Self::snapshots`].
1728    pub fn run_to_result(&mut self, z_obs: f64) -> crate::output::SolverResult {
1729        self.run_with_snapshots(&[z_obs]);
1730        let snapshot = self
1731            .snapshots
1732            .last()
1733            .expect("run_with_snapshots produced no snapshot")
1734            .clone();
1735        crate::output::SolverResult {
1736            snapshot,
1737            x_grid: self.grid.x.clone(),
1738            step_count: self.step_count,
1739            diag_newton_exhausted: self.diag.newton_exhausted,
1740            warnings: self.diag.warnings.clone(),
1741        }
1742    }
1743
1744    /// Create a builder for configuring a solver with a fluent API.
1745    ///
1746    /// # Example
1747    /// ```rust,no_run
1748    /// use spectroxide::prelude::*;
1749    ///
1750    /// let mut solver = ThermalizationSolver::builder(Cosmology::planck2018())
1751    ///     .grid(GridConfig::production())
1752    ///     .injection(InjectionScenario::SingleBurst {
1753    ///         z_h: 2e5, delta_rho_over_rho: 1e-5, sigma_z: 100.0,
1754    ///     })
1755    ///     .z_range(5e5, 1e3)
1756    ///     .build()
1757    ///     .unwrap();
1758    /// ```
1759    pub fn builder(cosmo: Cosmology) -> SolverBuilder {
1760        SolverBuilder::new(cosmo)
1761    }
1762}
1763
1764/// Fluent builder for [`ThermalizationSolver`].
1765///
1766/// Groups all configuration into a chainable API. The existing
1767/// `ThermalizationSolver::new()` + manual field mutation still works;
1768/// this is a convenience layer on top.
1769pub struct SolverBuilder {
1770    cosmo: Cosmology,
1771    grid_config: GridConfig,
1772    injection: Option<InjectionScenario>,
1773    initial_delta_n: Option<Vec<f64>>,
1774    z_start: Option<f64>,
1775    z_end: Option<f64>,
1776    dy_max: Option<f64>,
1777    dz_min: Option<f64>,
1778    dtau_max: Option<f64>,
1779    nc_z_min: Option<f64>,
1780    disable_dcbr: bool,
1781    coupled_dcbr: bool,
1782    number_conserving: bool,
1783    dcbr_scale: f64,
1784    no_auto_refine: bool,
1785    max_newton_iter: Option<usize>,
1786    nc_stride: Option<usize>,
1787}
1788
1789impl SolverBuilder {
1790    fn new(cosmo: Cosmology) -> Self {
1791        SolverBuilder {
1792            cosmo,
1793            grid_config: GridConfig::default(),
1794            injection: None,
1795            initial_delta_n: None,
1796            z_start: None,
1797            z_end: None,
1798            dy_max: None,
1799            dz_min: None,
1800            dtau_max: None,
1801            nc_z_min: None,
1802            disable_dcbr: false,
1803            coupled_dcbr: true,
1804            number_conserving: true,
1805            dcbr_scale: 1.0,
1806            no_auto_refine: false,
1807            max_newton_iter: None,
1808            nc_stride: None,
1809        }
1810    }
1811
1812    /// Set the frequency grid configuration.
1813    pub fn grid(mut self, config: GridConfig) -> Self {
1814        self.grid_config = config;
1815        self
1816    }
1817
1818    /// Use the fast (500-point) grid for quick tests.
1819    pub fn grid_fast(mut self) -> Self {
1820        self.grid_config = GridConfig::fast();
1821        self
1822    }
1823
1824    /// Use the production (4000-point) grid for high-accuracy runs.
1825    pub fn grid_production(mut self) -> Self {
1826        self.grid_config = GridConfig::production();
1827        self
1828    }
1829
1830    /// Set the energy injection scenario.
1831    pub fn injection(mut self, scenario: InjectionScenario) -> Self {
1832        self.injection = Some(scenario);
1833        self
1834    }
1835
1836    /// Set an initial photon perturbation Δn(x).
1837    pub fn initial_delta_n(mut self, delta_n: Vec<f64>) -> Self {
1838        self.initial_delta_n = Some(delta_n);
1839        self
1840    }
1841
1842    /// Set the redshift range (z_start, z_end).
1843    pub fn z_range(mut self, z_start: f64, z_end: f64) -> Self {
1844        self.z_start = Some(z_start);
1845        self.z_end = Some(z_end);
1846        self
1847    }
1848
1849    /// Set a complete solver config, overriding individual z/dy/dtau settings.
1850    pub fn solver_config(mut self, config: SolverConfig) -> Self {
1851        self.z_start = Some(config.z_start);
1852        self.z_end = Some(config.z_end);
1853        self.dy_max = Some(config.dy_max);
1854        self.dz_min = Some(config.dz_min);
1855        self.dtau_max = Some(config.dtau_max);
1856        self.nc_z_min = Some(config.nc_z_min);
1857        self.max_newton_iter = Some(config.max_newton_iter);
1858        self
1859    }
1860
1861    /// Set the maximum Kompaneets step size.
1862    pub fn dy_max(mut self, val: f64) -> Self {
1863        self.dy_max = Some(val);
1864        self
1865    }
1866
1867    /// Set the maximum Compton optical depth per step.
1868    pub fn dtau_max(mut self, val: f64) -> Self {
1869        self.dtau_max = Some(val);
1870        self
1871    }
1872
1873    /// Disable DC/BR processes (Kompaneets only).
1874    pub fn disable_dcbr(mut self) -> Self {
1875        self.disable_dcbr = true;
1876        self
1877    }
1878
1879    /// Use operator-split DC/BR instead of coupled IMEX.
1880    pub fn split_dcbr(mut self) -> Self {
1881        self.coupled_dcbr = false;
1882        self
1883    }
1884
1885    /// Enable number-conserving T-shift subtraction (on by default).
1886    pub fn number_conserving(mut self) -> Self {
1887        self.number_conserving = true;
1888        self
1889    }
1890
1891    /// Disable number-conserving T-shift subtraction.
1892    pub fn no_number_conserving(mut self) -> Self {
1893        self.number_conserving = false;
1894        self
1895    }
1896
1897    /// Set the minimum redshift for number-conserving subtraction.
1898    pub fn nc_z_min(mut self, val: f64) -> Self {
1899        self.nc_z_min = Some(val);
1900        self
1901    }
1902
1903    /// Set the NC stripping stride (strip every N steps).
1904    pub fn nc_stride(mut self, val: usize) -> Self {
1905        self.nc_stride = Some(val);
1906        self
1907    }
1908
1909    /// Scale DC/BR emission rates by this factor (diagnostic).
1910    pub fn dcbr_scale(mut self, val: f64) -> Self {
1911        self.dcbr_scale = val;
1912        self
1913    }
1914
1915    /// Set the maximum number of Newton iterations per Kompaneets step.
1916    pub fn max_newton_iter(mut self, val: usize) -> Self {
1917        self.max_newton_iter = Some(val);
1918        self
1919    }
1920
1921    /// Disable automatic refinement zone insertion for photon injection scenarios.
1922    pub fn no_auto_refine(mut self) -> Self {
1923        self.no_auto_refine = true;
1924        self
1925    }
1926
1927    /// Build the configured solver.
1928    ///
1929    /// Validates all configuration (cosmology, grid, solver config, injection)
1930    /// before constructing the solver. Returns `Err` with a descriptive message
1931    /// if any parameter is invalid.
1932    pub fn build(self) -> Result<ThermalizationSolver, String> {
1933        // Validate all configuration
1934        self.cosmo.validate()?;
1935
1936        let mut grid_config = self.grid_config;
1937
1938        // Auto-apply refinement zones and x_min adjustment from injection scenario
1939        if !self.no_auto_refine {
1940            if let Some(ref inj) = self.injection {
1941                for zone in inj.refinement_zones() {
1942                    grid_config.refinement_zones.push(zone);
1943                }
1944                // Lower x_min for low-frequency photon injection to prevent
1945                // boundary absorption (Dirichlet BC eats photons at x_min).
1946                if let Some(x_min) = inj.suggested_x_min() {
1947                    if x_min < grid_config.x_min {
1948                        grid_config.x_min = x_min;
1949                    }
1950                }
1951            }
1952        }
1953
1954        grid_config.validate()?;
1955
1956        let defaults = SolverConfig::default();
1957
1958        // For DarkPhotonResonance the impulsive Δn depletion happens at the
1959        // NWA resonance z_res; evolving from a higher z_start is unphysical
1960        // (the conversion hasn't occurred yet) and evolving from lower misses
1961        // it entirely. Default z_start to z_res when the user didn't supply
1962        // an explicit value.
1963        let dp_z_res = self
1964            .injection
1965            .as_ref()
1966            .and_then(|inj| inj.dark_photon_params(&self.cosmo))
1967            .map(|(_gamma, z_res)| z_res);
1968        let resolved_z_start = match (self.z_start, dp_z_res) {
1969            (Some(z), _) => z,
1970            (None, Some(z_res)) => z_res,
1971            (None, None) => defaults.z_start,
1972        };
1973
1974        let config = SolverConfig {
1975            z_start: resolved_z_start,
1976            z_end: self.z_end.unwrap_or(defaults.z_end),
1977            dy_max: self.dy_max.unwrap_or(defaults.dy_max),
1978            dz_min: self.dz_min.unwrap_or(defaults.dz_min),
1979            dtau_max: self.dtau_max.unwrap_or(defaults.dtau_max),
1980            nc_z_min: self.nc_z_min.unwrap_or(defaults.nc_z_min),
1981            dtau_max_photon_source: defaults.dtau_max_photon_source,
1982            max_newton_iter: self.max_newton_iter.unwrap_or(defaults.max_newton_iter),
1983            cn_dcbr: defaults.cn_dcbr,
1984        };
1985        config.validate()?;
1986
1987        let mut deferred_warnings: Vec<String> = config.soft_warnings();
1988
1989        if let Some(ref inj) = self.injection {
1990            inj.validate()?;
1991
1992            // Check z_start is high enough to capture the injection
1993            if let Some((_z_center, z_upper)) = inj.characteristic_redshift() {
1994                if config.z_start < z_upper {
1995                    return Err(format!(
1996                        "z_start={:.1e} is below the injection window upper bound z={:.1e}. \
1997                         The solver would miss part or all of the injection. \
1998                         Set z_start >= {:.1e}.",
1999                        config.z_start, z_upper, z_upper
2000                    ));
2001                }
2002            }
2003
2004            // Warn if the caller explicitly asked for a z_start that doesn't
2005            // coincide with the dark-photon resonance: the NWA IC is installed
2006            // there, so mismatched z_start accumulates spurious pre-resonance
2007            // thermalisation (or skips the resonance entirely).
2008            if let (Some(z_user), Some(z_res)) = (self.z_start, dp_z_res) {
2009                if (z_user - z_res).abs() > 1e-6 * z_res {
2010                    deferred_warnings.push(format!(
2011                        "DarkPhotonResonance: z_start={z_user:.3e} differs from NWA resonance \
2012                         redshift z_res={z_res:.3e}; μ/y will include spurious evolution between \
2013                         these redshifts. Omit z_start to auto-set it to z_res."
2014                    ));
2015                }
2016            }
2017
2018            for warning in inj.warn_strong_distortion() {
2019                deferred_warnings.push(warning);
2020            }
2021            for warning in inj.warn_tabulated_coverage(config.z_start, config.z_end) {
2022                deferred_warnings.push(warning);
2023            }
2024            for warning in inj.warn_dark_photon_range(&self.cosmo) {
2025                deferred_warnings.push(warning);
2026            }
2027        }
2028
2029        let mut solver = ThermalizationSolver::new(self.cosmo, grid_config);
2030        solver.set_config(config);
2031        solver.disable_dcbr = self.disable_dcbr;
2032        solver.coupled_dcbr = self.coupled_dcbr;
2033        solver.number_conserving = self.number_conserving;
2034        solver.dcbr_scale = self.dcbr_scale;
2035        if let Some(stride) = self.nc_stride {
2036            solver.nc_stride = stride;
2037        }
2038
2039        if let Some(scenario) = self.injection {
2040            solver.set_injection(scenario)?;
2041        }
2042
2043        if let Some(dn) = self.initial_delta_n {
2044            solver.set_initial_delta_n(dn);
2045        }
2046
2047        solver.diag.warnings.extend(deferred_warnings);
2048
2049        Ok(solver)
2050    }
2051}
2052
2053#[cfg(test)]
2054mod tests {
2055    use super::*;
2056
2057    #[test]
2058    fn test_no_injection_stays_planck() {
2059        let cosmo = Cosmology::default();
2060        let mut solver = ThermalizationSolver::new(cosmo, GridConfig::fast());
2061        solver.set_config(SolverConfig {
2062            z_start: 1.0e6,
2063            z_end: 1.0e5,
2064            ..SolverConfig::default()
2065        });
2066        for _ in 0..100 {
2067            if solver.z <= solver.config.z_end {
2068                break;
2069            }
2070            solver.step();
2071        }
2072        let max_dn: f64 = solver.delta_n.iter().map(|x| x.abs()).fold(0.0, |a, b| {
2073            if a.is_nan() || b.is_nan() {
2074                f64::NAN
2075            } else {
2076                a.max(b)
2077            }
2078        });
2079        // With Λ·ρ_e adiabatic cooling (correct formula), even a Planck
2080        // spectrum develops a small distortion from expansion cooling.
2081        // The signal is O(Λ) ~ 10⁻⁸, which is physical (adiabatic cooling).
2082        assert!(max_dn < 1e-6, "max|Δn| = {max_dn}");
2083    }
2084
2085    #[test]
2086    fn test_pde_vs_greens_mu_era() {
2087        // Inject energy at z=2×10⁵ (deep μ-era).
2088        // The PDE solver should produce μ ≈ 1.4e-5, matching the Green's
2089        // function prediction to within ~10%.
2090        let cosmo = Cosmology::default();
2091        let z_h = 2.0e5;
2092        let drho = 1e-5;
2093        let sigma = 3000.0;
2094
2095        let mut solver = ThermalizationSolver::new(cosmo.clone(), GridConfig::default());
2096        solver
2097            .set_injection(InjectionScenario::SingleBurst {
2098                z_h,
2099                delta_rho_over_rho: drho,
2100                sigma_z: sigma,
2101            })
2102            .unwrap();
2103        solver.set_config(SolverConfig {
2104            z_start: 5.0e5,
2105            z_end: 1.0e4,
2106            ..SolverConfig::default()
2107        });
2108
2109        let snaps = solver.run_with_snapshots(&[1.0e4]);
2110        let last = snaps.last().unwrap();
2111
2112        let dq_dz = |z: f64| -> f64 {
2113            drho * (-(z - z_h).powi(2) / (2.0 * sigma * sigma)).exp()
2114                / (2.0 * std::f64::consts::PI * sigma * sigma).sqrt()
2115        };
2116        let mu_gf = crate::greens::mu_from_heating(&dq_dz, 1e3, 5e6, 10000);
2117
2118        eprintln!(
2119            "μ-era: PDE μ={:.4e} y={:.4e} Δρ/ρ={:.4e}, GF μ={:.4e}",
2120            last.mu, last.y, last.delta_rho_over_rho, mu_gf
2121        );
2122
2123        // Energy conservation
2124        let energy_err = (last.delta_rho_over_rho - drho).abs() / drho;
2125        assert!(
2126            energy_err < 0.1,
2127            "μ-era energy: Δρ/ρ={:.4e} vs injected {drho:.4e}",
2128            last.delta_rho_over_rho
2129        );
2130
2131        // μ should match GF to within 12%
2132        let mu_err = (last.mu - mu_gf).abs() / mu_gf.abs().max(1e-20);
2133        eprintln!("  μ PDE/GF agreement: {:.1}%", (1.0 - mu_err) * 100.0);
2134        assert!(
2135            mu_err < 0.12,
2136            "μ PDE={:.4e} vs GF={:.4e}, err={:.1}%",
2137            last.mu,
2138            mu_gf,
2139            mu_err * 100.0
2140        );
2141    }
2142
2143    #[test]
2144    fn test_pde_vs_greens_y_era() {
2145        // Inject energy at z=5000 (y-era).
2146        // PDE solver y should match Green's function prediction within ~50%.
2147        let cosmo = Cosmology::default();
2148        let z_h = 5000.0;
2149        let drho = 1e-5;
2150        let sigma = 200.0;
2151
2152        let mut solver = ThermalizationSolver::new(cosmo.clone(), GridConfig::default());
2153        solver
2154            .set_injection(InjectionScenario::SingleBurst {
2155                z_h,
2156                delta_rho_over_rho: drho,
2157                sigma_z: sigma,
2158            })
2159            .unwrap();
2160        solver.set_config(SolverConfig {
2161            z_start: 1.0e4,
2162            z_end: 1.0e3,
2163            ..SolverConfig::default()
2164        });
2165
2166        let snaps = solver.run_with_snapshots(&[1.0e3]);
2167        let last = snaps.last().unwrap();
2168
2169        // Green's function: use positive dq_dz (heating convention)
2170        let dq_dz = |z: f64| -> f64 {
2171            drho * (-(z - z_h).powi(2) / (2.0 * sigma * sigma)).exp()
2172                / (2.0 * std::f64::consts::PI * sigma * sigma).sqrt()
2173        };
2174        let y_gf = crate::greens::y_from_heating(&dq_dz, 1e2, 1e5, 10000);
2175
2176        let rel_err = (last.y - y_gf).abs() / y_gf.abs().max(1e-20);
2177        eprintln!(
2178            "y-era: PDE y={:.4e}, GF y={:.4e}, rel_err={:.1}%",
2179            last.y,
2180            y_gf,
2181            rel_err * 100.0
2182        );
2183        assert!(
2184            rel_err < 0.10,
2185            "PDE y={:.4e} vs GF y={:.4e}, rel_err={rel_err:.2}",
2186            last.y,
2187            y_gf
2188        );
2189    }
2190
2191    #[test]
2192    fn test_energy_conservation() {
2193        // Energy injected should approximately match Δρ/ρ in the final spectrum.
2194        let cosmo = Cosmology::default();
2195        let drho = 1e-5;
2196
2197        let mut solver = ThermalizationSolver::new(cosmo, GridConfig::default());
2198        solver
2199            .set_injection(InjectionScenario::SingleBurst {
2200                z_h: 5e4,
2201                delta_rho_over_rho: drho,
2202                sigma_z: 2000.0,
2203            })
2204            .unwrap();
2205        solver.set_config(SolverConfig {
2206            z_start: 2.0e5,
2207            z_end: 1.0e3,
2208            ..SolverConfig::default()
2209        });
2210
2211        let snaps = solver.run_with_snapshots(&[1.0e3]);
2212        let last = snaps.last().unwrap();
2213
2214        let rel_err = (last.delta_rho_over_rho - drho).abs() / drho;
2215        eprintln!(
2216            "Energy conservation: Δρ/ρ_out={:.4e}, Δρ/ρ_in={:.4e}, rel_err={:.1}%",
2217            last.delta_rho_over_rho,
2218            drho,
2219            rel_err * 100.0
2220        );
2221        assert!(
2222            rel_err < 0.05,
2223            "Energy not conserved: Δρ/ρ_out={:.4e} vs {:.4e}",
2224            last.delta_rho_over_rho,
2225            drho
2226        );
2227    }
2228
2229    #[test]
2230    fn test_pde_y_at_multiple_redshifts() {
2231        // Cross-validate PDE vs Green's function at z=10⁴, 3×10⁴, 5×10⁴
2232        let cosmo = Cosmology::default();
2233        let drho = 1e-5;
2234
2235        for &(z_h, sigma) in &[(1e4, 500.0), (3e4, 1000.0), (5e4, 2000.0)] {
2236            let mut solver = ThermalizationSolver::new(cosmo.clone(), GridConfig::default());
2237            solver
2238                .set_injection(InjectionScenario::SingleBurst {
2239                    z_h,
2240                    delta_rho_over_rho: drho,
2241                    sigma_z: sigma,
2242                })
2243                .unwrap();
2244            solver.set_config(SolverConfig {
2245                z_start: z_h * 3.0,
2246                z_end: 500.0,
2247                ..SolverConfig::default()
2248            });
2249            let snaps = solver.run_with_snapshots(&[500.0]);
2250            let last = snaps.last().unwrap();
2251
2252            let dq_dz = |z: f64| -> f64 {
2253                drho * (-(z - z_h).powi(2) / (2.0 * sigma * sigma)).exp()
2254                    / (2.0 * std::f64::consts::PI * sigma * sigma).sqrt()
2255            };
2256            let y_gf = crate::greens::y_from_heating(&dq_dz, 1e2, z_h * 5.0, 10000);
2257
2258            let energy_err = (last.delta_rho_over_rho - drho).abs() / drho;
2259            eprintln!(
2260                "z_h={z_h:.0e}: PDE y={:.4e} GF y={:.4e}, Δρ/ρ={:.4e}, E_err={:.1}%",
2261                last.y,
2262                y_gf,
2263                last.delta_rho_over_rho,
2264                energy_err * 100.0
2265            );
2266
2267            assert!(
2268                energy_err < 0.1,
2269                "Energy not conserved at z_h={z_h}: {:.4e} vs {drho:.4e}",
2270                last.delta_rho_over_rho
2271            );
2272        }
2273    }
2274
2275    #[test]
2276    fn test_greens_function_consistency() {
2277        // Verify Green's function μ and y have correct scaling with Δρ/ρ
2278        let dq1 = |z: f64| -> f64 {
2279            1e-5 * (-(z - 3e5_f64).powi(2) / (2.0 * 5000.0_f64.powi(2))).exp()
2280                / (2.0 * std::f64::consts::PI * 5000.0_f64.powi(2)).sqrt()
2281        };
2282        let dq2 = |z: f64| -> f64 {
2283            2e-5 * (-(z - 3e5_f64).powi(2) / (2.0 * 5000.0_f64.powi(2))).exp()
2284                / (2.0 * std::f64::consts::PI * 5000.0_f64.powi(2)).sqrt()
2285        };
2286
2287        let mu1 = crate::greens::mu_from_heating(&dq1, 1e3, 5e6, 5000);
2288        let mu2 = crate::greens::mu_from_heating(&dq2, 1e3, 5e6, 5000);
2289
2290        // μ should scale linearly with Δρ/ρ
2291        let ratio = mu2 / mu1;
2292        assert!(
2293            (ratio - 2.0).abs() < 0.01,
2294            "μ should scale linearly: μ₂/μ₁ = {ratio:.4} (expected 2.0)"
2295        );
2296    }
2297
2298    #[test]
2299    fn test_decomposition_pure_tshift() {
2300        // A pure temperature shift Δn = ε·G(x) should decompose to μ=0, y=0.
2301        let cosmo = Cosmology::default();
2302        let mut solver = ThermalizationSolver::new(cosmo, GridConfig::default());
2303
2304        let eps = 1e-5;
2305        for i in 0..solver.grid.n {
2306            let x = solver.grid.x[i];
2307            solver.delta_n[i] = eps * crate::spectrum::g_bb(x);
2308        }
2309
2310        let (mu, y) = solver.extract_mu_y_joint();
2311        let drho = crate::spectrum::delta_rho_over_rho(&solver.grid.x, &solver.delta_n);
2312        eprintln!("Pure T-shift (ε={eps:.0e}): μ={mu:.4e}, y={y:.4e}, Δρ/ρ={drho:.4e}");
2313        eprintln!("  Expected: μ≈0, y≈0, Δρ/ρ≈{:.4e}", 4.0 * eps);
2314
2315        assert!(
2316            mu.abs() < 5e-8,
2317            "μ should be ~0 for pure T-shift: μ={mu:.4e}"
2318        );
2319        assert!(y.abs() < 5e-8, "y should be ~0 for pure T-shift: y={y:.4e}");
2320    }
2321
2322    #[test]
2323    fn test_decomposition_pure_mu() {
2324        // A pure μ distortion Δn = μ·M(x) should decompose to correct μ.
2325        let cosmo = Cosmology::default();
2326        let mut solver = ThermalizationSolver::new(cosmo, GridConfig::default());
2327
2328        let mu_val = 1e-5;
2329        for i in 0..solver.grid.n {
2330            let x = solver.grid.x[i];
2331            solver.delta_n[i] = mu_val * crate::spectrum::mu_shape(x);
2332        }
2333
2334        let (mu, y) = solver.extract_mu_y_joint();
2335        let drho = crate::spectrum::delta_rho_over_rho(&solver.grid.x, &solver.delta_n);
2336        eprintln!("Pure μ (μ_in={mu_val:.0e}): μ_out={mu:.4e}, y={y:.4e}, Δρ/ρ={drho:.4e}");
2337
2338        let rel_err = (mu - mu_val).abs() / mu_val;
2339        assert!(
2340            rel_err < 0.05,
2341            "μ extraction error: {rel_err:.2}% (μ_out={mu:.4e} vs {mu_val:.4e})"
2342        );
2343        assert!(y.abs() < 1e-7, "y should be ~0 for pure μ: y={y:.4e}");
2344    }
2345
2346    #[test]
2347    fn test_snapshots_at_requested_redshifts() {
2348        let cosmo = Cosmology::default();
2349        let mut solver = ThermalizationSolver::new(cosmo, GridConfig::fast());
2350        solver
2351            .set_injection(InjectionScenario::SingleBurst {
2352                z_h: 5e4,
2353                delta_rho_over_rho: 1e-5,
2354                sigma_z: 2000.0,
2355            })
2356            .unwrap();
2357        solver.set_config(SolverConfig {
2358            z_start: 2.0e5,
2359            z_end: 500.0,
2360            ..SolverConfig::default()
2361        });
2362
2363        let requested = [1.5e5, 1e5, 5e4, 1e4, 5e3, 1e3];
2364        let snaps = solver.run_with_snapshots(&requested);
2365
2366        assert_eq!(snaps.len(), requested.len());
2367        for (snap, &z_req) in snaps.iter().zip(requested.iter()) {
2368            let rel_err = (snap.z - z_req).abs() / z_req;
2369            assert!(
2370                rel_err < 0.001,
2371                "Snapshot z={:.1} should be at requested z={:.1}, rel_err={:.4}",
2372                snap.z,
2373                z_req,
2374                rel_err
2375            );
2376        }
2377    }
2378
2379    /// `.disable_dcbr()` produces the un-thermalized μ = 1.401 × Δρ/ρ exactly
2380    /// — no J_bb* suppression because DC/BR photon creation is off.
2381    ///
2382    /// Oracle:             pure Kompaneets (no photon sources): energy conserved,
2383    ///                     μ = (3/κ_c)·Δρ/ρ = 1.401·Δρ/ρ (SZ 1970 / Chluba 2013
2384    ///                     with J_bb* → 1 by construction).
2385    /// Expected:           μ = 1.401 × 10⁻⁵ for Δρ/ρ = 10⁻⁵
2386    /// Oracle uncertainty: 2% (Kompaneets convergence over Δτ ~ few)
2387    /// Tolerance:          5%
2388    ///
2389    /// Previous version asserted only μ > 0 and drho > 0 — sign-only; any
2390    /// 100× wrong normalization would have passed.
2391    #[test]
2392    fn test_solver_builder_disable_dcbr() {
2393        let drho = 1e-5_f64;
2394        let mut solver = SolverBuilder::new(Cosmology::default())
2395            .grid_fast()
2396            .injection(InjectionScenario::SingleBurst {
2397                z_h: 2e5,
2398                delta_rho_over_rho: drho,
2399                sigma_z: 100.0,
2400            })
2401            .z_range(2.1e5, 1e5)
2402            .disable_dcbr()
2403            .build()
2404            .unwrap();
2405
2406        solver.run_with_snapshots(&[1e5]);
2407        let last = solver.snapshots.last().unwrap();
2408
2409        let mu_expected = (3.0 / KAPPA_C) * drho; // = 1.401 * drho, no J_bb* with DC/BR off
2410        let mu_err = (last.mu - mu_expected).abs() / mu_expected;
2411        eprintln!(
2412            "disable_dcbr: μ = {:.4e}, expected (3/κ_c)·Δρ/ρ = {:.4e}, err = {:.2}%",
2413            last.mu,
2414            mu_expected,
2415            mu_err * 100.0
2416        );
2417        assert!(
2418            mu_err < 0.05,
2419            "disable_dcbr: μ = {:.4e} vs pure-Kompaneets target {:.4e} \
2420             (err {:.2}%, tol 5%)",
2421            last.mu,
2422            mu_expected,
2423            mu_err * 100.0
2424        );
2425
2426        // Energy conservation: pure Kompaneets preserves ∫x³ Δn dx exactly
2427        // modulo scheme residual.
2428        let drho_err = (last.delta_rho_over_rho - drho).abs() / drho;
2429        assert!(
2430            drho_err < 0.02,
2431            "disable_dcbr energy conservation: Δρ_out = {:.4e} vs {drho:.4e} \
2432             (err {:.2}%, tol 2%)",
2433            last.delta_rho_over_rho,
2434            drho_err * 100.0,
2435        );
2436    }
2437
2438    #[test]
2439    fn test_solver_builder_split_dcbr() {
2440        // Operator-split DC/BR mode
2441        let mut solver = SolverBuilder::new(Cosmology::default())
2442            .grid_fast()
2443            .injection(InjectionScenario::SingleBurst {
2444                z_h: 2e5,
2445                delta_rho_over_rho: 1e-5,
2446                sigma_z: 100.0,
2447            })
2448            .z_range(2.1e5, 1e5)
2449            .split_dcbr()
2450            .build()
2451            .unwrap();
2452
2453        solver.run_with_snapshots(&[1e5]);
2454        let last = solver.snapshots.last().unwrap();
2455        assert!(last.delta_rho_over_rho.is_finite());
2456        // Split DC/BR should give positive μ for positive injection
2457        assert!(
2458            last.mu > 0.0,
2459            "split_dcbr: μ should be positive for heating: {:.4e}",
2460            last.mu
2461        );
2462    }
2463
2464    #[test]
2465    fn test_solver_reset() {
2466        let cosmo = Cosmology::default();
2467        let mut solver = ThermalizationSolver::new(cosmo, GridConfig::fast());
2468        solver
2469            .set_injection(InjectionScenario::SingleBurst {
2470                z_h: 5e4,
2471                delta_rho_over_rho: 1e-5,
2472                sigma_z: 2000.0,
2473            })
2474            .unwrap();
2475        solver.set_config(SolverConfig {
2476            z_start: 1e5,
2477            z_end: 1e4,
2478            ..SolverConfig::default()
2479        });
2480
2481        // First run
2482        solver.run_with_snapshots(&[1e4]);
2483        let mu1 = solver.snapshots.last().unwrap().mu;
2484        assert!(mu1.abs() > 0.0);
2485
2486        // Reset clears state
2487        solver.reset();
2488        assert!(solver.snapshots.is_empty());
2489        assert!(solver.delta_n.iter().all(|&v| v == 0.0));
2490        assert!(solver.injection.is_none());
2491
2492        // Re-configure injection and z range, then run again
2493        solver
2494            .set_injection(InjectionScenario::SingleBurst {
2495                z_h: 5e4,
2496                delta_rho_over_rho: 1e-5,
2497                sigma_z: 2000.0,
2498            })
2499            .unwrap();
2500        solver.set_config(SolverConfig {
2501            z_start: 1e5,
2502            z_end: 1e4,
2503            ..SolverConfig::default()
2504        });
2505        solver.run_with_snapshots(&[1e4]);
2506        let mu2 = solver.snapshots.last().unwrap().mu;
2507
2508        // Should be reproducible
2509        let diff = (mu1 - mu2).abs() / mu1.abs().max(1e-20);
2510        assert!(
2511            diff < 1e-10,
2512            "Reset should give identical results: {mu1:.4e} vs {mu2:.4e}"
2513        );
2514    }
2515
2516    #[test]
2517    fn test_solver_run_to_result() {
2518        let mut solver = SolverBuilder::new(Cosmology::default())
2519            .grid_fast()
2520            .injection(InjectionScenario::SingleBurst {
2521                z_h: 5e4,
2522                delta_rho_over_rho: 1e-5,
2523                sigma_z: 2000.0,
2524            })
2525            .z_range(1e5, 1e4)
2526            .build()
2527            .unwrap();
2528
2529        let result = solver.run_to_result(1e4);
2530        assert!(result.step_count > 0);
2531        assert!(!result.x_grid.is_empty());
2532        // run_to_result returns the snapshot at z_obs (≈1e4 here).
2533        assert!(result.snapshot.z < 1.1e4);
2534
2535        // Can serialize to JSON
2536        let json = result.to_json();
2537        assert!(json.contains("\"pde_mu\":"));
2538        assert!(json.contains("\"diag_newton_exhausted\":"));
2539    }
2540}