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}