Skip to main content

nereids_physics/
ikeda_carpenter.rs

1//! Ikeda–Carpenter analytical moderator resolution model.
2//!
3//! A third instrument-resolution model alongside the analytical Gaussian
4//! ([`crate::resolution::ResolutionParams`]) and the Monte-Carlo tabulated
5//! kernel ([`crate::resolution::TabulatedResolution`]). It exists to settle a
6//! methodological dispute about the VENUS instrument resolution: one camp
7//! trusts the MC-simulated tabulated kernel (UDR/FTS file); the instrument
8//! scientist distrusts the unproven MC and prefers an analytical
9//! Ikeda–Carpenter moderator model. NEREIDS implements IC as a first-class
10//! model so all three can be cross-validated against synthetic loop-closure
11//! and real VENUS data.
12//!
13//! # Physics — the Ikeda–Carpenter pulse
14//!
15//! Reference: S. Ikeda & J. M. Carpenter, *Nucl. Instrum. Methods* **A239**
16//! (1985) 536–544. The neutron emission-time distribution from a pulsed
17//! spallation moderator is
18//!
19//! ```text
20//!   I(τ) = (1−R)·g₃(τ;α)  +  R·[g₃(·;α) ⊛ β e^{−β·}](τ),    τ ≥ 0
21//! ```
22//!
23//! where the prompt (slowing-down) term is a Gamma/Erlang density of shape 3:
24//!
25//! ```text
26//!   g₃(τ;α) = α³ τ² e^{−ατ} / 2          (mode 2/α, mean 3/α, ∫ = 1)
27//! ```
28//!
29//! and the delayed (storage) term is `g₃` convolved with an exponential of
30//! rate β. The convolution has the closed form
31//!
32//! ```text
33//!   g₃(·;α) ⊛ β e^{−β·} (τ) = β (α/γ)³ [ e^{−βτ} − e^{−ατ}(1 + γτ + ½γ²τ²) ],  γ = α−β
34//! ```
35//!
36//! (re-derived and confirmed against the Codex independent derivation and the
37//! SAMMY-RPI χ²+double-exp moderator form). Both terms are individually
38//! unit-area, so `I` is unit-area for any α,β>0 and 0≤R≤1, with first moment
39//! `⟨τ⟩ = 3/α + R/β`. The asymmetry — sharp rise, long tail toward larger τ
40//! (later TOF, lower apparent energy) — is the physical origin of the
41//! asymmetric MC kernel.
42//!
43//! ## Parameters and their energy dependence
44//!
45//! - `α(E)` [1/µs]: fast moderation/leakage rate; sets the prompt width.
46//!   Leading epithermal scaling `α ∝ √E` (Mantid `α = 1/(α₀+α₁λ)`, λ ∝ 1/√E).
47//! - `β` [1/µs]: slow storage rate; sets the delayed tail. Energy-independent.
48//! - `R(E)`, 0 ≤ R ≤ 1: storage mixing fraction; `R ≈ exp(−E_meV/κ)` → **R→0 in
49//!   the 1–200 eV resonance regime**, so IC there is dominated by the
50//!   one-parameter prompt Gamma(3, α(E)) term.
51//!
52//! Parameters are **fixed** when the instrument scientist provides them, or
53//! **fit** from a known calibration foil otherwise (general case).
54//!
55//! ## Time → energy kernel and centering
56//!
57//! For a flight path `L`, a resonance at `E_r` has nominal TOF
58//! `t_r = TOF_FACTOR·L/√E_r`. A neutron with emission delay τ arrives at TOF
59//! `t_r+τ`, apparent energy `E' = (TOF_FACTOR·L/(t_r+τ))²` — that is the kernel's
60//! *definition* (its positive-τ tail is delayed emission). Sampling `I(τ)` on
61//! a τ-grid and mapping to TOF-offsets yields exactly the `(offset, weight)`
62//! kernel representation that [`crate::resolution::TabulatedResolution`]
63//! consumes — so IC rides the *same* verified broadening machinery, whose
64//! *application* is the convolution gather: the broadened value at measured TOF
65//! `t` reads theory at `t−τ` (a neutron measured at `t` with delay τ really flew
66//! `t−τ`; see `resolution::broaden` and SAMMY `udr/mudr4.f90` `Ud_Convolute`).
67//! At apply time
68//! `interpolated_kernel` blends the two bracketing reference kernels as a
69//! width-normalized shape blend (offsets scaled to the geometrically
70//! interpolated width, shapes merged on the union grid) — unequal point counts
71//! from IC's per-kernel tail trimming no longer trigger a nearest-reference
72//! fallback, so the between-reference width follows the physical power law
73//! smoothly. IC also synthesizes a dense reference
74//! grid (default 64 energies), so the between-reference error is negligible. The
75//! kernel is anchored with its **mode at offset 0** (peak-centering), matching the
76//! UDR file convention (peak at offset 0); `interpolated_kernel` does not
77//! re-center it. Because the IC pulse is right-skewed, its *mean* lags its mode by
78//! ~1/α(E) in TOF, so the centroid — and even the minimum — of a broadened
79//! resonance shifts toward **lower apparent energy** by an α(E)-dependent amount
80//! (order 1e-2 eV, ~1e-3 relative, for α≈1.5 in the eV regime; larger toward
81//! lower energy). So it *does* move the broadened dip off the nominal energy,
82//! by a small amount.
83//!
84//! For the prompt-only law `α(E) = a0·√E + a1` with `R≈0`, the lag `~1/α(E)` is
85//! **exactly** the `1/√E` basis of a flight-path (`L_scale`) error *iff* `a1 = 0`:
86//! then the centroid offset scales as `c/√E`, and an `L → L(1+δ)` change shifts
87//! every TOF by `δ·K·L/√E`, also `∝ 1/√E`, so the two are degenerate. With `a1 ≠ 0`
88//! the lag `1/(a0√E + a1)` only *approximately* follows that basis (and the
89//! storage term `R/β`, negligible in the eV regime, adds a further small
90//! departure). To leading order, then, the lag is **confounded with the energy
91//! scale**, and is handled by the SHARED `(t0, L_scale)` energy scale, not by any
92//! per-family knob:
93//! - **Run-time fitting** fits the `t0`/`L_scale` energy-scale, which absorbs the
94//!   constant-`L` part of the lag exactly (same basis); only the *shape*
95//!   (skew/tail) of the asymmetry is not absorbable by position.
96//! - **Resolution calibration** (the `nereids-fitting` calibrator) **pins** the
97//!   energy scale by default — a pure shape/width fit. It can optionally fit a
98//!   SHARED `(t0, L_scale)` under a metrology prior (`with_position_prior`) for
99//!   joint energy-scale / identifiability work. A *free, per-family* position knob
100//!   is deliberately NOT used: because the lag is the same basis as `L_scale`, a
101//!   free position lets a wrong (symmetric) family imitate the asymmetric shift
102//!   and erodes the model-selection χ² (the discriminator is then only the
103//!   position-independent skew/tail). See `nereids-fitting`'s
104//!   `free_l_scale_absorbs_asymmetric_lag_and_erodes_discrimination`.
105//!
106//! `ic_centering_shifts_broadened_symmetric_dip_with_alpha` quantifies the bare
107//! mode→centroid shift; the calibrator's `fit_t0_recovers_injected_energy_scale_shift`
108//! checks the energy-scale fit recovers an injected offset. Re-centering the kernel
109//! on its centroid (a future convention change spanning the UDR path too) would
110//! remove the shift at the source.
111//!
112//! ## Optional instrument convolutions
113//!
114//! The full instrument function folds the moderator with a proton-burst
115//! (Gaussian σ) and a chopper/channel (triangle, FWHM) term. Both are optional
116//! here (`None` ⇒ omitted).
117//!
118//! **Provenance of the triangle (`channel_fwhm_us`).** SAMMY broadens for the
119//! accelerator burst either as a Gaussian of FWHM `DELTAG` (SAMMY Manual R8
120//! Sec. III.C.1.a, eq. III C1 a.12) or as a square pulse of width `BURST`
121//! (Sec. III.C.2.a). At SNS the proton pulse delivered to the target is shaped
122//! by the accumulator ring (Proton Storage Ring, PSR) into an approximately
123//! triangular ~700 ns base — FWHM ≈ 350 ns — which is what the VENUS tabulated
124//! FTS kernel header records as "folded triang FWHM 350 ns PSR". NEREIDS folds
125//! that PSR triangle via `channel_fwhm_us` (symmetric triangle, half-base =
126//! FWHM, `triangle_kernel`). Note: a tabulated file whose header says the
127//! triangle is already "folded" in must NOT be double-counted against an IC
128//! model that also applies it (the `nereids-fitting` calibrator therefore
129//! applies its `psr_fwhm_ns` fold to the IC family only, never to
130//! tabulated/UDR kernels).
131
132use crate::resolution::{ResolutionParseError, TabulatedResolution};
133
134/// de Broglie wavelength factor: λ (Å) = `LAMBDA_ANGSTROM_FACTOR` / √(E in eV).
135///
136/// `λ = h/√(2·m_n·E)`; with CODATA 2018 `h`, `m_n` and the 2019-SI eV this is
137/// `0.285993 Å·√eV`. Used only by [`EnergyLaw::InverseLambda`]; its precise
138/// value folds into the fitted `α₀,α₁`, so high precision is not load-bearing.
139const LAMBDA_ANGSTROM_FACTOR: f64 = 0.285_993;
140
141/// Rates below this (1/µs) are clamped to keep the pulse well-defined.
142const MIN_RATE: f64 = 1e-9;
143
144/// Relative-to-peak weight below which kernel tails are trimmed.
145const TRIM_REL: f64 = 1e-7;
146
147/// Default number of log-spaced reference energies in a synthesized table.
148pub const DEFAULT_N_ENERGIES: usize = 64;
149
150/// Default number of τ-samples spanning the prompt core of each kernel.
151pub const DEFAULT_N_TAU: usize = 600;
152
153/// Minimum accepted `n_tau` (τ-samples across the prompt core). Doubles as
154/// the module's prompt-core **resolution floor**: when [`MAX_TAU_SAMPLES`]
155/// widens the τ-step (see [`tau_geometry`]), the step may never exceed
156/// `fast_reach / (MIN_N_TAU − 1)` — the coarsest prompt sampling
157/// [`IkedaCarpenter::new`] has ever accepted as valid via its `n_tau ≥ 8`
158/// check.
159const MIN_N_TAU: usize = 8;
160
161/// Minimum samples per side SPANNING a sampled channel triangle
162/// (`dtau ≤ FWHM / 3`, half-base = FWHM). At the exactly-admitted boundary
163/// `dtau = FWHM/3` the per-side samples sit at `{FWHM/3, 2FWHM/3, FWHM}` —
164/// triangle weights `{2/3, 1/3, 0}` — so each side carries ≥ 2 strictly
165/// interior (nonzero) samples while the endpoint lands ON the triangle
166/// zero; the sampled fold is distinctly non-delta (discrete variance
167/// 4·FWHM²/27 ≈ 89 % of the analytic FWHM²/6). Coarser steps degenerate
168/// the discrete triangle toward the exact delta `[0, 1, 0]`, silently
169/// erasing a requested fold — [`tau_geometry`] rejects that instead
170/// (strictly: `capped_step > FWHM/3`).
171const TRI_MIN_SAMPLES_PER_SIDE: f64 = 3.0;
172
173/// Reach of the sampled/folded Gaussian burst in standard deviations (±4σ:
174/// e^{−8} ≈ 3.4e-4 of peak at the edge, well below any consuming tolerance
175/// once convolved). Also fixes the burst resolution floor `dtau ≤ σ`
176/// (≥ `GAUSS_REACH_SIGMAS` samples per side).
177const GAUSS_REACH_SIGMAS: f64 = 4.0;
178
179/// Prompt-tail reach in e-folds: the τ-grid spans `FAST_REACH_E_FOLDS / α`
180/// so the prompt Gamma(3) tail `τ²e^{−ατ}` is < ~1e-8 of peak at the edge.
181const FAST_REACH_E_FOLDS: f64 = 18.0;
182
183/// Slow/storage-tail reach in e-folds (`SLOW_REACH_E_FOLDS / β` when storage
184/// is active). Sits AT the trim horizon: `e^{−16} ≈ 1.1e-7 ≈` [`TRIM_REL`]
185/// (`ln(1/TRIM_REL) ≈ 16.1`), so the reach cannot be truncated shorter
186/// without discarding tail weight the [`TRIM_REL`] trim would keep — no
187/// hidden approximation lives in this constant.
188const SLOW_REACH_E_FOLDS: f64 = 16.0;
189
190/// Storage fractions below this are treated as "no storage tail" when sizing
191/// the τ-grid. DELIBERATELY two decades below [`TRIM_REL`], not derived from
192/// it: whether a slow tail actually survives the [`TRIM_REL`] trim depends on
193/// the α/β contrast (the trim threshold is relative to the prompt-dominated
194/// peak, and the slow term's peak weight scales with both R and β/α), so no
195/// single constant derived from [`TRIM_REL`] is exact for every admitted
196/// (α, β) — treating a larger R as absent would be a hidden approximation.
197/// The margin's only consequence is conservatism: for R ∈ (1e-9, ~1e-7) the
198/// τ-grid is sized — and the [`MAX_TAU_SAMPLES`] cap gate applied — for a
199/// slow tail whose weight the trim then discards anyway, spending samples
200/// (or rejecting a configuration) for physics that cannot appear in the
201/// kernel. It can never drop tail weight the trim would have kept.
202const R_NEGLIGIBLE: f64 = 1e-9;
203
204/// Cap on the τ-sample count spanning the PULSE BODY (`[0, τ_max]`) of one
205/// synthesized kernel. The τ-step is anchored to the prompt core and refined
206/// to resolve active folds (see [`tau_geometry`]), so a long storage tail
207/// (β ≪ α with R > 0) grows the sample COUNT rather than the step; this cap
208/// bounds that growth (CPU/memory) by widening the step to
209/// `tau_max / (MAX_TAU_SAMPLES − 1)` — but never past the resolution floor
210/// (prompt core: `fast_reach / (MIN_N_TAU − 1)`; triangle: FWHM /
211/// [`TRI_MIN_SAMPLES_PER_SIDE`]; burst: σ). A parameter/grid combination
212/// whose floor cannot be met within the cap is REJECTED loudly by
213/// [`IkedaCarpenter::new`] instead of silently under-sampled. The actual
214/// guarantee is on the pulse body only: the symmetric burst/channel fold
215/// margin (± `4σ + FWHM` at the resolved step) adds its samples ON TOP of
216/// the cap, so the final grid can exceed `MAX_TAU_SAMPLES` by the margin
217/// sample count.
218const MAX_TAU_SAMPLES: usize = 8192;
219
220/// Taylor expansion of `h(u)/u³` where `h(u) = 1 − e^{−u}(1 + u + ½u²)`, for
221/// `|u|` small (the `α ≈ β` limit), where direct evaluation cancels
222/// catastrophically. As `u → 0`, `h(u)/u³ → 1/6`.
223#[inline]
224fn h_over_cube_taylor(u: f64) -> f64 {
225    // h(u)/u³ = 1/6 − u/8 + u²/20 − u³/72 + u⁴/336 + O(u⁵). Carrying the u⁴ term
226    // makes the bounded↔Taylor branch boundary (|u|=0.05) continuous to ~1e-11,
227    // below any tolerance that consumes the synthesized kernel.
228    let u2 = u * u;
229    1.0 / 6.0 - u / 8.0 + u2 / 20.0 - u2 * u / 72.0 + u2 * u2 / 336.0
230}
231
232/// Ikeda–Carpenter moderator emission density `I(τ)`.
233///
234/// `τ` in µs, rates `α,β` in 1/µs, mixing `r ∈ [0,1]`. Returns 0 for `τ < 0`.
235/// Unit-area over `τ ∈ [0,∞)`; first moment `3/α + r/β`. **NaN-free** for all
236/// `α,β > 0` (including `α ≈ β` and `β ≫ α`): the slow/storage term is evaluated
237/// from the bounded bracket `e^{−βτ} − e^{−ατ}(1+u+½u²)` (both exponentials ≤ 1
238/// for `τ,α,β > 0`, so no `e^{|u|}` overflow), falling back to a Taylor limit
239/// near `u = α−β·τ → 0` where that bracket cancels.
240#[must_use]
241pub fn ic_pulse(alpha: f64, beta: f64, r: f64, tau: f64) -> f64 {
242    if !tau.is_finite() || tau < 0.0 {
243        return 0.0;
244    }
245    let alpha = alpha.max(MIN_RATE);
246    let beta = beta.max(MIN_RATE);
247    let at = alpha * tau;
248    // prompt Gamma(3,α): α³τ²/2 · e^{−ατ} = (α/2)(ατ)² e^{−ατ}
249    let fast = 0.5 * alpha * at * at * (-at).exp();
250    if r <= 0.0 {
251        return fast;
252    }
253    // slow/storage term = β(α/γ)³[e^{−βτ} − e^{−ατ}(1+u+½u²)], u = γτ = (α−β)τ.
254    let u = (alpha - beta) * tau;
255    let coeff = beta * alpha.powi(3) * tau.powi(3);
256    let slow = if u.abs() < 0.05 {
257        // α ≈ β: bracket/u³ → e^{−βτ}·h(u)/u³ (Taylor); avoids 0/0 cancellation.
258        coeff * (-beta * tau).exp() * h_over_cube_taylor(u)
259    } else {
260        // Bounded form: both exponentials are ≤ 1, so β ≫ α (u ≪ 0) cannot
261        // overflow (the old `e^{−βτ}·h(u)` factored an `e^{|u|}` → 0·∞ = NaN).
262        let bracket = (-beta * tau).exp() - (-alpha * tau).exp() * (1.0 + u + 0.5 * u * u);
263        coeff * bracket / (u * u * u)
264    };
265    (1.0 - r) * fast + r * slow
266}
267
268/// Energy-dependence law for an Ikeda–Carpenter parameter.
269///
270/// A small closed set so the *fixed-or-fit* cases share one representation:
271/// fixed ⇒ [`Const`](EnergyLaw::Const); fit ⇒ a parametric law whose
272/// coefficients are the fit variables.
273#[derive(Debug, Clone, PartialEq)]
274pub enum EnergyLaw {
275    /// Energy-independent constant.
276    Const(f64),
277    /// `a0·√(E[eV]) + a1` — leading epithermal scaling of the fast rate `α(E)`.
278    SqrtE { a0: f64, a1: f64 },
279    /// Mantid IC form `1/(a0 + a1·λ)`, λ (Å) = `LAMBDA_ANGSTROM_FACTOR`/√E.
280    /// Behaves as `α ∝ √E` at low E and saturates to `1/a0` at high E.
281    InverseLambda { a0: f64, a1: f64 },
282    /// `exp(−E[meV]/kappa)` — storage fraction `R(E)`, → 0 in the eV regime.
283    ExpMilliEv { kappa: f64 },
284}
285
286impl EnergyLaw {
287    /// Evaluate the law at `energy_ev` (eV). Non-positive energy yields the
288    /// `E→0` limit where well-defined (and a clamped value otherwise).
289    #[must_use]
290    pub fn eval(&self, energy_ev: f64) -> f64 {
291        let e = energy_ev.max(0.0);
292        match *self {
293            EnergyLaw::Const(c) => c,
294            EnergyLaw::SqrtE { a0, a1 } => a0 * e.sqrt() + a1,
295            EnergyLaw::InverseLambda { a0, a1 } => {
296                let lambda = if e > 0.0 {
297                    LAMBDA_ANGSTROM_FACTOR / e.sqrt()
298                } else {
299                    f64::INFINITY
300                };
301                let denom = a0 + a1 * lambda;
302                if denom.abs() < MIN_RATE {
303                    1.0 / MIN_RATE
304                } else {
305                    1.0 / denom
306                }
307            }
308            EnergyLaw::ExpMilliEv { kappa } => {
309                if kappa.abs() < MIN_RATE {
310                    0.0
311                } else {
312                    (-(e * 1000.0) / kappa).exp()
313                }
314            }
315        }
316    }
317}
318
319/// Parameters of the Ikeda–Carpenter resolution model.
320#[derive(Debug, Clone)]
321pub struct IkedaCarpenterParams {
322    /// Fast (slowing-down) rate `α(E)`, 1/µs. Must evaluate to > 0.
323    pub alpha: EnergyLaw,
324    /// Slow (storage) rate `β`, 1/µs. Energy-independent, must be > 0.
325    pub beta: f64,
326    /// Storage mixing fraction `R(E)`, 0 ≤ R ≤ 1.
327    pub r: EnergyLaw,
328    /// Optional proton-burst Gaussian standard deviation (µs).
329    pub burst_sigma_us: Option<f64>,
330    /// Optional chopper/channel triangle FWHM (µs).
331    pub channel_fwhm_us: Option<f64>,
332}
333
334impl IkedaCarpenterParams {
335    /// A pure-moderator parameter set (no burst, no channel) with constant
336    /// rates — the simplest case, useful for fixed-parameter or unit-test use.
337    #[must_use]
338    pub fn constant(alpha: f64, beta: f64, r: f64) -> Self {
339        Self {
340            alpha: EnergyLaw::Const(alpha),
341            beta,
342            r: EnergyLaw::Const(r),
343            burst_sigma_us: None,
344            channel_fwhm_us: None,
345        }
346    }
347}
348
349/// Configuration of the energy / time grids used to synthesize the IC kernel
350/// table. The energy grid spans the data range densely enough that interref
351/// interpolation error is negligible.
352#[derive(Debug, Clone)]
353pub struct SynthesisGrid {
354    /// Lowest reference energy (eV), > 0.
355    pub e_min_ev: f64,
356    /// Highest reference energy (eV), > `e_min_ev`.
357    pub e_max_ev: f64,
358    /// Number of log-spaced reference energies (≥ 2).
359    pub n_energies: usize,
360    /// Number of τ-samples spanning the prompt core of each kernel
361    /// (≥ [`MIN_N_TAU`](crate::ikeda_carpenter) = 8). A long storage tail
362    /// (β ≪ α with R > 0) grows the per-kernel sample count beyond `n_tau`;
363    /// that count is capped at 8192 (`MAX_TAU_SAMPLES`), past which the
364    /// τ-step widens — never below the resolution floor (prompt core at the
365    /// `n_tau = 8` density, folds at ≥ 3 triangle samples per side / ≥ 1
366    /// sample per burst σ): a combination that cannot be resolved within the
367    /// cap is rejected by [`IkedaCarpenter::new`]. Active burst/channel folds
368    /// add their ±(4σ + FWHM) margin samples on top of the cap.
369    pub n_tau: usize,
370}
371
372impl SynthesisGrid {
373    /// A sensible default grid: `[e_min, e_max]` log-spaced over
374    /// [`DEFAULT_N_ENERGIES`] points with [`DEFAULT_N_TAU`] τ-samples.
375    #[must_use]
376    pub fn new(e_min_ev: f64, e_max_ev: f64) -> Self {
377        Self {
378            e_min_ev,
379            e_max_ev,
380            n_energies: DEFAULT_N_ENERGIES,
381            n_tau: DEFAULT_N_TAU,
382        }
383    }
384}
385
386/// Analytical Ikeda–Carpenter resolution model.
387///
388/// Synthesizes a [`TabulatedResolution`] at construction and applies it through
389/// the same broadening path as a Monte-Carlo file. Cloning is cheap (the
390/// synthesized table is the only large field). Re-synthesize (construct anew)
391/// when fitting changes the parameters.
392#[derive(Debug, Clone)]
393pub struct IkedaCarpenter {
394    params: IkedaCarpenterParams,
395    flight_path_m: f64,
396    ref_energies: Vec<f64>,
397    n_tau: usize,
398    tabulated: TabulatedResolution,
399}
400
401impl IkedaCarpenter {
402    /// Build the model, synthesizing its kernel table over `grid`.
403    ///
404    /// # Errors
405    /// Returns [`ResolutionParseError::InvalidFormat`] for a non-positive
406    /// flight path, a degenerate grid (`n_energies < 2`, `n_tau < 8`,
407    /// `e_min ≤ 0`, `e_max ≤ e_min`), a non-positive `β`, a parameter/grid
408    /// combination whose τ-grid cannot resolve the prompt core and requested
409    /// folds within the `MAX_TAU_SAMPLES` cap at some reference energy (see
410    /// `tau_geometry` — remedy: larger `β`, `R = 0`, or a wider/disabled
411    /// fold), or if the synthesized kernels fail
412    /// [`TabulatedResolution::from_kernels`] validation.
413    pub fn new(
414        params: IkedaCarpenterParams,
415        flight_path_m: f64,
416        grid: &SynthesisGrid,
417    ) -> Result<Self, ResolutionParseError> {
418        if !flight_path_m.is_finite() || flight_path_m <= 0.0 {
419            return Err(ResolutionParseError::InvalidFormat(format!(
420                "Flight path must be a positive finite number, got {flight_path_m}"
421            )));
422        }
423        if grid.n_energies < 2 {
424            return Err(ResolutionParseError::InvalidFormat(format!(
425                "n_energies must be >= 2, got {}",
426                grid.n_energies
427            )));
428        }
429        if grid.n_tau < MIN_N_TAU {
430            return Err(ResolutionParseError::InvalidFormat(format!(
431                "n_tau must be >= {MIN_N_TAU}, got {}",
432                grid.n_tau
433            )));
434        }
435        if !grid.e_min_ev.is_finite()
436            || !grid.e_max_ev.is_finite()
437            || grid.e_min_ev <= 0.0
438            || grid.e_max_ev <= grid.e_min_ev
439        {
440            return Err(ResolutionParseError::InvalidFormat(format!(
441                "Require finite 0 < e_min < e_max, got [{}, {}]",
442                grid.e_min_ev, grid.e_max_ev
443            )));
444        }
445        if !params.beta.is_finite() || params.beta <= 0.0 {
446            return Err(ResolutionParseError::InvalidFormat(format!(
447                "beta must be a positive finite number, got {}",
448                params.beta
449            )));
450        }
451
452        let ln_lo = grid.e_min_ev.ln();
453        let ln_hi = grid.e_max_ev.ln();
454        let denom = (grid.n_energies - 1) as f64;
455        let ref_energies: Vec<f64> = (0..grid.n_energies)
456            .map(|i| (ln_lo + (i as f64 / denom) * (ln_hi - ln_lo)).exp())
457            .collect();
458
459        // Reject parameter laws that yield a non-positive fast rate α(E): the
460        // pulse would otherwise degenerate (synthesis clamps α to a tiny floor,
461        // producing a meaningless near-flat kernel rather than failing loudly).
462        if let Some(&bad) = ref_energies.iter().find(|&&e| {
463            let a = params.alpha.eval(e);
464            !a.is_finite() || a <= 0.0
465        }) {
466            return Err(ResolutionParseError::InvalidFormat(format!(
467                "Ikeda–Carpenter α(E) must be > 0, but α({bad}) = {} is not",
468                params.alpha.eval(bad)
469            )));
470        }
471        // Reject storage-fraction laws that fall outside [0, 1] (synthesis clamps
472        // R, masking a mis-specified law); a physical mixing fraction is in [0,1].
473        if let Some(&bad) = ref_energies.iter().find(|&&e| {
474            let r = params.r.eval(e);
475            !r.is_finite() || !(0.0..=1.0).contains(&r)
476        }) {
477            return Err(ResolutionParseError::InvalidFormat(format!(
478                "Ikeda–Carpenter R(E) must be in [0, 1], but R({bad}) = {} is not",
479                params.r.eval(bad)
480            )));
481        }
482        // Reject invalid optional instrument-convolution widths up front; synthesis
483        // otherwise masks a negative width via `.abs()` and silently swallows a NaN.
484        for (name, v) in [
485            ("burst_sigma_us", params.burst_sigma_us),
486            ("channel_fwhm_us", params.channel_fwhm_us),
487        ] {
488            if let Some(x) = v
489                && (!x.is_finite() || x < 0.0)
490            {
491                return Err(ResolutionParseError::InvalidFormat(format!(
492                    "Ikeda–Carpenter {name} must be finite and >= 0, got {x}"
493                )));
494            }
495        }
496
497        // Synthesis is fallible: a kernel whose τ-grid cannot resolve the
498        // requested physics within MAX_TAU_SAMPLES (long slow tail vs a fine
499        // fold / fast prompt core) errs loudly here instead of silently
500        // degrading — see `tau_geometry`.
501        let kernels: Vec<(Vec<f64>, Vec<f64>)> = ref_energies
502            .iter()
503            .map(|&e| synth_kernel(&params, grid.n_tau, e))
504            .collect::<Result<_, _>>()?;
505
506        let tabulated =
507            TabulatedResolution::from_kernels(ref_energies.clone(), kernels, flight_path_m)?;
508
509        Ok(Self {
510            params,
511            flight_path_m,
512            ref_energies,
513            n_tau: grid.n_tau,
514            tabulated,
515        })
516    }
517
518    /// The synthesized tabulated kernel set (the broadening engine).
519    #[must_use]
520    pub fn tabulated(&self) -> &TabulatedResolution {
521        &self.tabulated
522    }
523
524    /// The IC parameters.
525    #[must_use]
526    pub fn params(&self) -> &IkedaCarpenterParams {
527        &self.params
528    }
529
530    /// Flight-path length (m).
531    #[must_use]
532    pub fn flight_path_m(&self) -> f64 {
533        self.flight_path_m
534    }
535
536    /// Reference energies (eV, ascending) the table was synthesized on.
537    #[must_use]
538    pub fn ref_energies(&self) -> &[f64] {
539        &self.ref_energies
540    }
541
542    /// Evaluate the (burst/channel-folded) IC kernel at one energy.
543    ///
544    /// Returns ascending TOF-offsets (µs, mode at 0) and peak-normalized
545    /// weights (max = 1), matching the [`TabulatedResolution`] storage
546    /// convention.
547    ///
548    /// # Errors
549    /// Returns [`ResolutionParseError::InvalidFormat`] when the τ-grid cannot
550    /// resolve the prompt core and requested folds within `MAX_TAU_SAMPLES`
551    /// at this energy. Construction validates every *reference* energy, but a
552    /// probe energy outside `[e_min, e_max]` — for the √E law, above `e_max`,
553    /// where the larger α(E) imposes a finer prompt resolution floor against
554    /// the same storage-tail span — can still leave the resolvable region.
555    pub fn kernel_at(&self, energy_ev: f64) -> Result<(Vec<f64>, Vec<f64>), ResolutionParseError> {
556        synth_kernel(&self.params, self.n_tau, energy_ev)
557    }
558}
559
560/// τ-grid geometry for one kernel: `(dtau, tau_max, margin)`, or a
561/// descriptive error when no exact sampled representation fits the cap.
562///
563/// The step is anchored to the PROMPT core — `n_tau` samples across the fast
564/// Gamma(3) pulse (`fast_reach / (n_tau − 1)`) — and REFINED to resolve any
565/// requested instrument fold (triangle: ≥ [`TRI_MIN_SAMPLES_PER_SIDE`]
566/// samples per side, i.e. `dtau ≤ FWHM/3`; Gaussian burst: `dtau ≤ σ`, i.e.
567/// ≥ [`GAUSS_REACH_SIGMAS`] samples per ±4σ side). A longer storage tail
568/// (β ≪ α, R > 0) extends the SAMPLE COUNT (`j_hi ∝ tau_max/dtau`) instead
569/// of the step; [`MAX_TAU_SAMPLES`] bounds that count by widening the step —
570/// but never past the resolution FLOOR (`fast_reach / (MIN_N_TAU − 1)` for
571/// the prompt core, the fold minima above for folds). A combination whose
572/// floor cannot be met within the cap has no faithful sampled representation
573/// here, so it is rejected loudly: a capped step above the fold width would
574/// degenerate the sampled triangle to an exact delta `[0,1,0]` (the fold
575/// silently vanishes), and a capped step above the prompt scale steps OVER
576/// the prompt pulse entirely (probe: α = 250, β = 0.02, R = 0.1 loses the
577/// prompt's 0.9 weight share).
578///
579/// Bit-identical to the pre-#642-review `max(fast_reach/(n_tau−1),
580/// tau_max/(MAX_TAU_SAMPLES−1))` step whenever no fold is finer than the
581/// prompt design step and the capped step stays at or below the floor.
582fn tau_geometry(
583    params: &IkedaCarpenterParams,
584    n_tau: usize,
585    alpha: f64,
586    beta: f64,
587    r: f64,
588) -> Result<(f64, f64, f64), String> {
589    // τ_max: reach far enough that the prompt tail (e^{−ατ}) and, when
590    // storage is active, the slow tail (e^{−βτ}) are below the trim level.
591    let fast_reach = FAST_REACH_E_FOLDS / alpha;
592    let slow_reach = if r > R_NEGLIGIBLE {
593        SLOW_REACH_E_FOLDS / beta
594    } else {
595        0.0
596    };
597    let tau_max = fast_reach.max(slow_reach);
598
599    // Requested step and resolution floor. `floor ≥ dtau_req` always: the
600    // prompt terms satisfy MIN_N_TAU ≤ n_tau (validated by `new`) and the
601    // fold terms are common to both.
602    let mut dtau_req = fast_reach / (n_tau as f64 - 1.0);
603    let mut floor = fast_reach / (MIN_N_TAU as f64 - 1.0);
604    let mut fold_desc = String::new();
605    if let Some(fwhm) = params.channel_fwhm_us
606        && fwhm > 0.0
607    {
608        let tri_floor = fwhm / TRI_MIN_SAMPLES_PER_SIDE;
609        dtau_req = dtau_req.min(tri_floor);
610        floor = floor.min(tri_floor);
611        fold_desc.push_str(&format!(", channel triangle FWHM = {fwhm} µs"));
612    }
613    if let Some(sigma) = params.burst_sigma_us
614        && sigma > 0.0
615    {
616        dtau_req = dtau_req.min(sigma);
617        floor = floor.min(sigma);
618        fold_desc.push_str(&format!(", burst σ = {sigma} µs"));
619    }
620
621    let capped_step = tau_max / (MAX_TAU_SAMPLES as f64 - 1.0);
622    if capped_step > floor {
623        return Err(format!(
624            "the {MAX_TAU_SAMPLES}-sample τ-grid cap cannot resolve the requested physics: \
625             the pulse spans τ_max = {tau_max:.3} µs (α = {alpha:.4} µs⁻¹, β = {beta:.4} µs⁻¹, \
626             R = {r:.3}{fold_desc}), forcing a τ-step of {capped_step:.4} µs — above the \
627             finest-feature resolution floor of {floor:.4} µs. Increase β (shorter storage \
628             tail), set R = 0 (drop the storage term), or widen/disable the burst/channel fold"
629        ));
630    }
631    Ok((dtau_req.max(capped_step), tau_max, margin_of(params)))
632}
633
634/// Symmetric τ-grid margin for the burst/channel folds: ±(4σ + FWHM), the
635/// folds' full reach. These samples come ON TOP of [`MAX_TAU_SAMPLES`] (the
636/// cap governs the pulse body only).
637fn margin_of(params: &IkedaCarpenterParams) -> f64 {
638    params
639        .burst_sigma_us
640        .map_or(0.0, |s| GAUSS_REACH_SIGMAS * s)
641        + params.channel_fwhm_us.unwrap_or(0.0)
642}
643
644/// Synthesize one `(offsets, weights)` kernel for `energy_ev` from the IC
645/// parameters: sample `I(τ)`, fold in burst + channel, anchor the mode at
646/// offset 0, trim negligible tails, peak-normalize.
647///
648/// # Errors
649/// [`ResolutionParseError::InvalidFormat`] when [`tau_geometry`] cannot
650/// resolve the prompt core and requested folds within [`MAX_TAU_SAMPLES`].
651fn synth_kernel(
652    params: &IkedaCarpenterParams,
653    n_tau: usize,
654    energy_ev: f64,
655) -> Result<(Vec<f64>, Vec<f64>), ResolutionParseError> {
656    let alpha = params.alpha.eval(energy_ev).max(MIN_RATE);
657    let beta = params.beta.max(MIN_RATE);
658    let r = params.r.eval(energy_ev).clamp(0.0, 1.0);
659
660    let (dtau, tau_max, margin) = tau_geometry(params, n_tau, alpha, beta, r).map_err(|msg| {
661        ResolutionParseError::InvalidFormat(format!(
662            "Ikeda–Carpenter kernel at E = {energy_ev} eV: {msg}"
663        ))
664    })?;
665
666    // Extend the grid to slightly negative τ so a symmetric burst/channel can
667    // spread the leading edge correctly (the moderator pulse itself is 0 there).
668    // Widths are validated finite and >= 0 by `IkedaCarpenter::new`, so they are
669    // used directly (no `.abs()` masking of a sign error).
670    let j_lo: isize = -((margin / dtau).ceil() as isize);
671    let j_hi: isize = ((tau_max + margin) / dtau).ceil() as isize;
672
673    let taus: Vec<f64> = (j_lo..=j_hi).map(|j| j as f64 * dtau).collect();
674    let mut weights: Vec<f64> = taus.iter().map(|&t| ic_pulse(alpha, beta, r, t)).collect();
675
676    if let Some(sigma) = params.burst_sigma_us
677        && sigma > 0.0
678    {
679        weights = convolve_same(&weights, &gaussian_kernel(dtau, sigma));
680    }
681    if let Some(fwhm) = params.channel_fwhm_us
682        && fwhm > 0.0
683    {
684        weights = convolve_same(&weights, &triangle_kernel(dtau, fwhm));
685    }
686
687    // Anchor the mode at offset 0.
688    let peak_idx = argmax(&weights);
689    let peak_val = weights[peak_idx].max(f64::MIN_POSITIVE);
690    let tau_peak = taus[peak_idx];
691
692    // Trim tails below TRIM_REL of peak, keeping one guard sample each side so
693    // the convolution's neighbor-difference trapezoid widths stay defined.
694    let thresh = TRIM_REL * peak_val;
695    let lo = weights
696        .iter()
697        .position(|&w| w > thresh)
698        .map_or(0, |i| i.saturating_sub(1));
699    let hi = weights
700        .iter()
701        .rposition(|&w| w > thresh)
702        .map_or(weights.len() - 1, |i| (i + 1).min(weights.len() - 1));
703
704    let offsets: Vec<f64> = (lo..=hi).map(|j| taus[j] - tau_peak).collect();
705    let w: Vec<f64> = (lo..=hi).map(|j| weights[j] / peak_val).collect();
706    Ok((offsets, w))
707}
708
709/// Index of the maximum element (first on ties). Slice is non-empty by
710/// construction in [`synth_kernel`].
711fn argmax(xs: &[f64]) -> usize {
712    let mut best = 0;
713    let mut best_v = xs[0];
714    for (i, &x) in xs.iter().enumerate().skip(1) {
715        if x > best_v {
716            best_v = x;
717            best = i;
718        }
719    }
720    best
721}
722
723/// Symmetric, unit-sum Gaussian kernel sampled on a `dtau`-spaced grid out to
724/// ±[`GAUSS_REACH_SIGMAS`]·σ.
725fn gaussian_kernel(dtau: f64, sigma: f64) -> Vec<f64> {
726    let half = ((GAUSS_REACH_SIGMAS * sigma / dtau).ceil() as isize).max(1);
727    let mut k: Vec<f64> = (-half..=half)
728        .map(|j| {
729            let t = j as f64 * dtau / sigma;
730            (-0.5 * t * t).exp()
731        })
732        .collect();
733    normalize_sum(&mut k);
734    k
735}
736
737/// Symmetric, unit-sum triangle kernel of FWHM `fwhm` (half-base = FWHM;
738/// variance FWHM²/6) sampled on a `dtau`-spaced grid.
739fn triangle_kernel(dtau: f64, fwhm: f64) -> Vec<f64> {
740    let a = fwhm; // half-base equals FWHM for a symmetric triangle
741    let half = ((a / dtau).ceil() as isize).max(1);
742    let mut k: Vec<f64> = (-half..=half)
743        .map(|j| (1.0 - (j as f64 * dtau).abs() / a).max(0.0))
744        .collect();
745    normalize_sum(&mut k);
746    k
747}
748
749fn normalize_sum(k: &mut [f64]) {
750    let s: f64 = k.iter().sum();
751    if s > 0.0 {
752        for v in k.iter_mut() {
753            *v /= s;
754        }
755    }
756}
757
758/// Discrete convolution with a centered symmetric `kernel` (odd length),
759/// returning an output the same length as `input` (zero-padded edges).
760fn convolve_same(input: &[f64], kernel: &[f64]) -> Vec<f64> {
761    let n = input.len();
762    let kh = (kernel.len() / 2) as isize;
763    let mut out = vec![0.0f64; n];
764    for (i, o) in out.iter_mut().enumerate() {
765        let mut acc = 0.0;
766        for (kk, &kv) in kernel.iter().enumerate() {
767            let src = i as isize + (kk as isize - kh);
768            if src >= 0 && (src as usize) < n {
769                acc += input[src as usize] * kv;
770            }
771        }
772        *o = acc;
773    }
774    out
775}
776
777#[cfg(test)]
778mod tests {
779    use super::*;
780    use crate::resolution::{
781        ResolutionFunction, apply_resolution, apply_resolution_with_plan, build_resolution_plan,
782    };
783    use std::sync::Arc;
784
785    /// Trapezoidal integral of `I(τ)` over a fine grid out to many decay times.
786    fn pulse_area(alpha: f64, beta: f64, r: f64) -> f64 {
787        let tau_max = (18.0 / alpha).max(if r > 0.0 { 18.0 / beta } else { 0.0 });
788        let n = 200_000;
789        let dt = tau_max / n as f64;
790        let mut area = 0.0;
791        for i in 0..n {
792            let t0 = i as f64 * dt;
793            let t1 = (i + 1) as f64 * dt;
794            area += 0.5 * (ic_pulse(alpha, beta, r, t0) + ic_pulse(alpha, beta, r, t1)) * dt;
795        }
796        area
797    }
798
799    fn pulse_mean(alpha: f64, beta: f64, r: f64) -> f64 {
800        let tau_max = (24.0 / alpha).max(if r > 0.0 { 24.0 / beta } else { 0.0 });
801        let n = 400_000;
802        let dt = tau_max / n as f64;
803        let mut m = 0.0;
804        for i in 0..n {
805            let t = (i as f64 + 0.5) * dt;
806            m += t * ic_pulse(alpha, beta, r, t) * dt;
807        }
808        m
809    }
810
811    #[test]
812    fn pulse_is_unit_area() {
813        for &(a, b, r) in &[
814            (0.5, 0.05, 0.0),
815            (1.0, 0.1, 0.3),
816            (2.0, 0.2, 0.6),
817            (0.8, 0.5, 0.9),
818        ] {
819            let area = pulse_area(a, b, r);
820            assert!(
821                (area - 1.0).abs() < 1e-3,
822                "area for (α={a},β={b},R={r}) = {area}, expected 1"
823            );
824        }
825    }
826
827    #[test]
828    fn pulse_mean_matches_formula() {
829        // ⟨τ⟩ = 3/α + R/β
830        for &(a, b, r) in &[(1.0, 0.1, 0.0), (1.0, 0.1, 0.4), (2.0, 0.25, 0.7)] {
831            let want = 3.0 / a + r / b;
832            let got = pulse_mean(a, b, r);
833            assert!(
834                (got - want).abs() / want < 2e-3,
835                "mean (α={a},β={b},R={r}) = {got}, expected {want}"
836            );
837        }
838    }
839
840    #[test]
841    fn pulse_mode_is_two_over_alpha_for_pure_fast() {
842        // r=0 ⇒ Gamma(3,α): mode at τ=2/α.
843        let alpha = 1.3;
844        let mode = 2.0 / alpha;
845        let here = ic_pulse(alpha, 0.1, 0.0, mode);
846        for d in [-0.3, -0.1, 0.1, 0.3] {
847            assert!(ic_pulse(alpha, 0.1, 0.0, mode + d) <= here + 1e-12);
848        }
849    }
850
851    #[test]
852    fn pulse_alpha_equals_beta_is_finite_gamma4() {
853        // α=β limit: slow term → α⁴τ³/6·e^{−ατ} (Gamma(4)). r=1 isolates it.
854        let a = 1.0;
855        let tau = 2.5;
856        let got = ic_pulse(a, a, 1.0, tau);
857        let want = a.powi(4) * tau.powi(3) / 6.0 * (-a * tau).exp();
858        assert!(got.is_finite());
859        assert!(
860            (got - want).abs() < 1e-9,
861            "α=β pulse {got} != Gamma(4) {want}"
862        );
863        // Near-degenerate (β just below α) must also be finite and close.
864        let near = ic_pulse(a, a - 1e-9, 1.0, tau);
865        assert!(near.is_finite() && (near - want).abs() < 1e-6);
866    }
867
868    #[test]
869    fn pulse_is_finite_for_beta_much_greater_than_alpha() {
870        // Regression: β ≫ α with storage active previously produced NaN (the
871        // old e^{−βτ}·h(u) form factored an e^{|u|} that overflowed → 0·∞ = NaN).
872        for &tau in &[0.0, 1.0, 50.0, 400.0, 1000.0] {
873            let v = ic_pulse(0.05, 4.0, 0.5, tau);
874            assert!(
875                v.is_finite() && v >= 0.0,
876                "ic_pulse(0.05,4,0.5,{tau}) = {v}"
877            );
878        }
879        // The synthesized kernel must contain no non-finite entries.
880        let p = IkedaCarpenterParams {
881            alpha: EnergyLaw::Const(0.05),
882            beta: 4.0,
883            r: EnergyLaw::Const(0.5),
884            burst_sigma_us: None,
885            channel_fwhm_us: None,
886        };
887        let (offs, wts) = synth_kernel(&p, 600, 1.0).unwrap();
888        assert!(offs.iter().chain(wts.iter()).all(|v| v.is_finite()));
889    }
890
891    #[test]
892    fn pulse_is_nonnegative_and_zero_before_t0() {
893        assert_eq!(ic_pulse(1.0, 0.1, 0.5, -0.5), 0.0);
894        for i in 0..200 {
895            let t = i as f64 * 0.1;
896            assert!(ic_pulse(1.0, 0.1, 0.5, t) >= 0.0);
897        }
898    }
899
900    #[test]
901    fn energy_law_eval() {
902        assert_eq!(EnergyLaw::Const(3.0).eval(50.0), 3.0);
903        let s = EnergyLaw::SqrtE { a0: 0.2, a1: 0.1 };
904        assert!((s.eval(100.0) - (0.2 * 10.0 + 0.1)).abs() < 1e-12);
905        // InverseLambda: α grows with E (λ shrinks).
906        let il = EnergyLaw::InverseLambda { a0: 0.1, a1: 0.5 };
907        assert!(il.eval(200.0) > il.eval(5.0));
908        // R = exp(−E_meV/κ) → ~0 at eV-scale energies, ~1 at sub-meV.
909        let rr = EnergyLaw::ExpMilliEv { kappa: 25.0 };
910        assert!(rr.eval(10.0) < 1e-6); // 10 eV
911        assert!(rr.eval(0.001) > 0.9); // 1 meV
912    }
913
914    #[test]
915    fn kernel_mode_anchored_at_zero() {
916        let p = IkedaCarpenterParams::constant(1.0, 0.1, 0.3);
917        let (offsets, weights) = synth_kernel(&p, 600, 10.0).unwrap();
918        let peak = argmax(&weights);
919        // The peak offset is the closest to zero of all offsets.
920        let peak_abs = offsets[peak].abs();
921        for &o in &offsets {
922            assert!(peak_abs <= o.abs() + 1e-9);
923        }
924        assert!(peak_abs < (offsets[1] - offsets[0]).abs() + 1e-9);
925    }
926
927    #[test]
928    fn kernel_tail_points_to_positive_offset() {
929        // Asymmetry: longer/heavier tail toward +offset (later TOF, lower E).
930        let p = IkedaCarpenterParams::constant(1.0, 0.1, 0.2);
931        let (offsets, weights) = synth_kernel(&p, 600, 10.0).unwrap();
932        let max_pos = offsets.iter().cloned().fold(f64::MIN, f64::max);
933        let min_neg = offsets.iter().cloned().fold(f64::MAX, f64::min);
934        assert!(
935            max_pos > min_neg.abs(),
936            "expected longer +offset tail: +{max_pos} vs −{}",
937            min_neg.abs()
938        );
939        let pos_w: f64 = offsets
940            .iter()
941            .zip(&weights)
942            .filter(|(o, _)| **o > 0.0)
943            .map(|(_, w)| *w)
944            .sum();
945        let neg_w: f64 = offsets
946            .iter()
947            .zip(&weights)
948            .filter(|(o, _)| **o < 0.0)
949            .map(|(_, w)| *w)
950            .sum();
951        assert!(pos_w > neg_w, "expected more weight at +offset");
952    }
953
954    #[test]
955    fn higher_energy_gives_narrower_kernel() {
956        // α(E) ∝ √E ⇒ prompt width 1/α shrinks with E ⇒ smaller TOF support.
957        let p = IkedaCarpenterParams {
958            alpha: EnergyLaw::SqrtE { a0: 0.3, a1: 0.0 },
959            beta: 0.1,
960            r: EnergyLaw::Const(0.0),
961            burst_sigma_us: None,
962            channel_fwhm_us: None,
963        };
964        let support = |e: f64| {
965            let (o, _) = synth_kernel(&p, 600, e).unwrap();
966            o.iter().cloned().fold(f64::MIN, f64::max) - o.iter().cloned().fold(f64::MAX, f64::min)
967        };
968        assert!(support(100.0) < support(5.0));
969    }
970
971    #[test]
972    fn synthesize_builds_valid_ascending_table() {
973        let p = IkedaCarpenterParams::constant(1.0, 0.1, 0.2);
974        let grid = SynthesisGrid {
975            e_min_ev: 0.5e-3,
976            e_max_ev: 1000.0,
977            n_energies: 32,
978            n_tau: 400,
979        };
980        let ic = IkedaCarpenter::new(p, 25.0, &grid).expect("synthesis");
981        assert_eq!(ic.ref_energies().len(), 32);
982        assert_eq!(ic.tabulated().ref_energies().len(), 32);
983        for w in ic.ref_energies().windows(2) {
984            assert!(w[1] > w[0]);
985        }
986    }
987
988    #[test]
989    fn rejects_bad_config() {
990        let p = IkedaCarpenterParams::constant(1.0, 0.1, 0.2);
991        let bad = SynthesisGrid {
992            e_min_ev: 1.0,
993            e_max_ev: 0.5,
994            n_energies: 16,
995            n_tau: 100,
996        };
997        assert!(IkedaCarpenter::new(p.clone(), 25.0, &bad).is_err());
998        assert!(IkedaCarpenter::new(p, -1.0, &SynthesisGrid::new(1.0, 10.0)).is_err());
999        // A parameter law that yields α(E) ≤ 0 is rejected, not silently clamped.
1000        let neg_alpha = IkedaCarpenterParams {
1001            alpha: EnergyLaw::Const(-1.0),
1002            ..IkedaCarpenterParams::constant(1.0, 0.1, 0.0)
1003        };
1004        assert!(IkedaCarpenter::new(neg_alpha, 25.0, &SynthesisGrid::new(1.0, 100.0)).is_err());
1005        // Negative / non-finite burst or channel widths are rejected up front
1006        // (not `.abs()`-masked or NaN-swallowed during synthesis).
1007        for bad_width in [-1.0, f64::NAN, f64::INFINITY] {
1008            let neg_burst = IkedaCarpenterParams {
1009                burst_sigma_us: Some(bad_width),
1010                ..IkedaCarpenterParams::constant(1.0, 0.1, 0.0)
1011            };
1012            assert!(
1013                IkedaCarpenter::new(neg_burst, 25.0, &SynthesisGrid::new(1.0, 100.0)).is_err(),
1014                "burst_sigma_us={bad_width} should be rejected"
1015            );
1016            let neg_chan = IkedaCarpenterParams {
1017                channel_fwhm_us: Some(bad_width),
1018                ..IkedaCarpenterParams::constant(1.0, 0.1, 0.0)
1019            };
1020            assert!(
1021                IkedaCarpenter::new(neg_chan, 25.0, &SynthesisGrid::new(1.0, 100.0)).is_err(),
1022                "channel_fwhm_us={bad_width} should be rejected"
1023            );
1024        }
1025    }
1026
1027    #[test]
1028    fn broadens_constant_to_constant() {
1029        // An area-normalized kernel preserves a flat spectrum.
1030        let p = IkedaCarpenterParams::constant(1.0, 0.1, 0.3);
1031        let ic = IkedaCarpenter::new(p, 25.0, &SynthesisGrid::new(1.0, 200.0)).unwrap();
1032        let energies: Vec<f64> = (0..400).map(|i| 1.0 + i as f64 * 0.5).collect();
1033        let spectrum = vec![0.7f64; energies.len()];
1034        let res = ResolutionFunction::IkedaCarpenter(Arc::new(ic));
1035        let out = apply_resolution(&energies, &spectrum, &res).unwrap();
1036        // Interior points (away from grid edges where the kernel is clipped).
1037        for v in &out[40..energies.len() - 40] {
1038            assert!((v - 0.7).abs() < 1e-3, "flat broadening drifted: {v}");
1039        }
1040    }
1041
1042    #[test]
1043    fn ic_centering_shifts_broadened_symmetric_dip_with_alpha() {
1044        // The IC kernel anchors its MODE at offset 0, but the right-skewed pulse's
1045        // intensity centroid lags the mode by ~1/α in TOF (module docstring).
1046        // Broadening a symmetric-in-energy resonance therefore moves the dip
1047        // minimum off the nominal energy by an α-dependent amount. This guards the
1048        // documented bias (the loop-closure calibration tests cannot see it) and
1049        // pins it against a silent "fix" (e.g. re-centering on the centroid).
1050        const E0: f64 = 20.0;
1051        // Absorption-weighted centroid of the broadened dip — a robust position
1052        // estimator (the bare minimum is fragile under the wide low-α kernel).
1053        fn dip_centroid_energy(alpha: f64) -> f64 {
1054            // Constant-α kernel so the shape (hence the mode→mean lag) is uniform.
1055            let p = IkedaCarpenterParams::constant(alpha, 0.1, 0.0);
1056            let ic = IkedaCarpenter::new(p, 25.0, &SynthesisGrid::new(1.0, 200.0)).unwrap();
1057            let res = ResolutionFunction::IkedaCarpenter(Arc::new(ic));
1058            // Fine uniform grid; symmetric (in energy) Gaussian dip centered at E0.
1059            let energies: Vec<f64> = (0..4000).map(|i| 10.0 + i as f64 * 0.005).collect();
1060            let spectrum: Vec<f64> = energies
1061                .iter()
1062                .map(|&e| 1.0 - 0.8 * (-((e - E0) / 0.1).powi(2)).exp())
1063                .collect();
1064            let out = apply_resolution(&energies, &spectrum, &res).unwrap();
1065            let (mut num, mut den) = (0.0, 0.0);
1066            for (&e, &t) in energies.iter().zip(&out) {
1067                if (e - E0).abs() <= 2.0 {
1068                    let a = (1.0 - t).max(0.0); // absorption weight
1069                    num += e * a;
1070                    den += a;
1071                }
1072            }
1073            num / den
1074        }
1075        let signed_small_alpha = dip_centroid_energy(0.8) - E0;
1076        let signed_large_alpha = dip_centroid_energy(2.0) - E0;
1077        // The shift is toward LOWER apparent energy: the delayed-emission
1078        // tail gathers theory from earlier TOF (higher E), so the theory
1079        // dip at E0 surfaces at measured energies below E0. A positive
1080        // shift here means the kernel was applied time-mirrored.
1081        assert!(
1082            signed_small_alpha < 0.0 && signed_large_alpha < 0.0,
1083            "centering shift must be toward lower energy: \
1084             α=0.8 {signed_small_alpha:+e}, α=2.0 {signed_large_alpha:+e}"
1085        );
1086        let shift_small_alpha = signed_small_alpha.abs();
1087        let shift_large_alpha = signed_large_alpha.abs();
1088        // The bias is real (resolvable at the 1e-3 eV level)…
1089        assert!(
1090            shift_small_alpha > 1e-3,
1091            "centering shift vanished: {shift_small_alpha}"
1092        );
1093        // …and shrinks with increasing α (the ~1/α scaling of the mode→mean lag).
1094        assert!(
1095            shift_small_alpha > 1.3 * shift_large_alpha,
1096            "shift should scale ~1/α: α=0.8 {shift_small_alpha} vs α=2.0 {shift_large_alpha}"
1097        );
1098    }
1099
1100    #[test]
1101    fn plan_path_matches_direct_path() {
1102        let p = IkedaCarpenterParams::constant(1.2, 0.15, 0.25);
1103        let ic = IkedaCarpenter::new(p, 25.0, &SynthesisGrid::new(1.0, 200.0)).unwrap();
1104        let res = ResolutionFunction::IkedaCarpenter(Arc::new(ic));
1105        let energies: Vec<f64> = (0..300).map(|i| 1.0 + i as f64 * 0.6).collect();
1106        // A localized dip to exercise the convolution non-trivially.
1107        let spectrum: Vec<f64> = energies
1108            .iter()
1109            .map(|&e| if (e - 90.0).abs() < 5.0 { 0.2 } else { 1.0 })
1110            .collect();
1111        let direct = apply_resolution(&energies, &spectrum, &res).unwrap();
1112        let plan = build_resolution_plan(&energies, &res).unwrap();
1113        let planned =
1114            apply_resolution_with_plan(plan.as_ref(), &energies, &spectrum, &res).unwrap();
1115        assert_eq!(direct.len(), planned.len());
1116        for (a, b) in direct.iter().zip(&planned) {
1117            assert!((a - b).abs() < 1e-12, "plan vs direct mismatch: {a} vs {b}");
1118        }
1119    }
1120
1121    #[test]
1122    fn tau_step_anchors_to_prompt_core_not_storage_tail() {
1123        // Regression for the τ-grid fix: with a slow storage tail active
1124        // (β ≪ α, R > 0) the τ-step must stay anchored to the prompt core —
1125        // the old `tau_max/(n_tau−1)` step let the tail dilate dtau and
1126        // degenerate the 0.35 µs channel triangle toward a delta.
1127        let n_tau = 600;
1128        let alpha = 2.0; // fast_reach = 18/α = 9 µs
1129        let prompt_step = (18.0 / alpha) / (n_tau as f64 - 1.0);
1130
1131        // (a) Moderate tail (β = 0.25 ⇒ slow_reach = 64 µs): the cap does not
1132        // bite, so the step equals the prompt-core step exactly and the grid
1133        // still spans the full tail.
1134        let p = IkedaCarpenterParams {
1135            channel_fwhm_us: Some(0.35),
1136            ..IkedaCarpenterParams::constant(alpha, 0.25, 0.5)
1137        };
1138        let (offs, wts) = synth_kernel(&p, n_tau, 10.0).unwrap();
1139        let dtau = offs[1] - offs[0];
1140        assert!(
1141            (dtau - prompt_step).abs() < 1e-12,
1142            "uncapped step {dtau} != prompt-core step {prompt_step}"
1143        );
1144        assert!(
1145            dtau <= prompt_step + 1e-12,
1146            "prompt-core spacing {dtau} > fast_reach/(n_tau−1) = {prompt_step}"
1147        );
1148        // ≥ 3 nonzero triangle samples per side at this step.
1149        let tri = triangle_kernel(dtau, 0.35);
1150        let nonzero_per_side = tri.iter().take(tri.len() / 2).filter(|&&v| v > 0.0).count();
1151        assert!(
1152            nonzero_per_side >= 3,
1153            "triangle degenerated: {nonzero_per_side} nonzero samples per side"
1154        );
1155        // The tail is still reached: for β = 0.25 the slow tail crosses the
1156        // TRIM_REL = 1e-7 trim level near τ ≈ 63 µs (e^{−βτ}·slow-amplitude ÷
1157        // peak = 1e-7 at τ ≈ 62.6 µs), so the trimmed kernel must still span
1158        // well past 40 µs — a step anchored to τ_max instead of the prompt
1159        // core would pass a narrow-span check, but a grid that stops short of
1160        // the storage tail would not survive this one.
1161        let span = offs.last().unwrap() - offs.first().unwrap();
1162        let peak = wts.iter().cloned().fold(f64::MIN, f64::max);
1163        assert!((peak - 1.0).abs() < 1e-12, "weights not peak-normalized");
1164        assert!(span > 3.0 / alpha, "kernel span {span} lost the pulse body");
1165        assert!(
1166            span > 40.0,
1167            "kernel span {span} µs stops short of the β = 0.25 storage tail \
1168             (trim horizon ≈ 63 µs)"
1169        );
1170
1171        // (b) Extreme admitted tail (β = 0.02 ⇒ slow_reach = 800 µs): the
1172        // MAX_TAU_SAMPLES cap bites; the step widens to tau_max/(cap−1) but
1173        // must still resolve the 0.35 µs triangle with ≥ 3 samples per side
1174        // (the old tail-anchored step was 800/599 ≈ 1.34 µs — a delta).
1175        let p_ext = IkedaCarpenterParams {
1176            channel_fwhm_us: Some(0.35),
1177            ..IkedaCarpenterParams::constant(alpha, 0.02, 0.5)
1178        };
1179        let (offs_ext, _) = synth_kernel(&p_ext, n_tau, 10.0).unwrap();
1180        let dtau_ext = offs_ext[1] - offs_ext[0];
1181        let capped_step = 800.0 / (MAX_TAU_SAMPLES as f64 - 1.0);
1182        assert!(
1183            (dtau_ext - capped_step).abs() < 1e-9,
1184            "capped step {dtau_ext} != tau_max/(MAX_TAU_SAMPLES−1) = {capped_step}"
1185        );
1186        assert!(
1187            dtau_ext <= 0.35 / 3.0,
1188            "capped step {dtau_ext} cannot resolve the 0.35 µs triangle"
1189        );
1190        let tri_ext = triangle_kernel(dtau_ext, 0.35);
1191        let nonzero_ext = tri_ext
1192            .iter()
1193            .take(tri_ext.len() / 2)
1194            .filter(|&&v| v > 0.0)
1195            .count();
1196        assert!(
1197            nonzero_ext >= 3,
1198            "triangle degenerated under cap: {nonzero_ext} nonzero samples per side"
1199        );
1200    }
1201
1202    #[test]
1203    fn burst_and_channel_broaden_further() {
1204        // Folding in burst + channel widens the kernel TOF support.
1205        let base = IkedaCarpenterParams::constant(1.0, 0.1, 0.0);
1206        let (o0, _) = synth_kernel(&base, 600, 10.0).unwrap();
1207        let support0 = o0.iter().cloned().fold(f64::MIN, f64::max)
1208            - o0.iter().cloned().fold(f64::MAX, f64::min);
1209        let folded = IkedaCarpenterParams {
1210            burst_sigma_us: Some(0.3),
1211            channel_fwhm_us: Some(0.35),
1212            ..IkedaCarpenterParams::constant(1.0, 0.1, 0.0)
1213        };
1214        let (o1, _) = synth_kernel(&folded, 600, 10.0).unwrap();
1215        let support1 = o1.iter().cloned().fold(f64::MIN, f64::max)
1216            - o1.iter().cloned().fold(f64::MAX, f64::min);
1217        assert!(support1 > support0, "folded {support1} !> bare {support0}");
1218    }
1219
1220    /// Sum-weighted variance of a sampled `(offsets, weights)` kernel on a
1221    /// uniform τ-grid. Normalization-independent (divides by Σw).
1222    fn kernel_variance(offsets: &[f64], weights: &[f64]) -> f64 {
1223        let w_sum: f64 = weights.iter().sum();
1224        let mean: f64 = offsets.iter().zip(weights).map(|(o, w)| o * w).sum::<f64>() / w_sum;
1225        offsets
1226            .iter()
1227            .zip(weights)
1228            .map(|(o, w)| (o - mean).powi(2) * w)
1229            .sum::<f64>()
1230            / w_sum
1231    }
1232
1233    #[test]
1234    fn unresolvable_tau_grid_is_rejected_loudly() {
1235        // Review #645 F1, probe 1: β = 0.005 ⇒ slow reach 16/β = 3200 µs ⇒
1236        // capped step 3200/8191 ≈ 0.39 µs > the 0.35 µs triangle FWHM — the
1237        // sampled triangle would be the exact delta [0, 1, 0] and the
1238        // requested fold would silently vanish from the kernel. Must be a
1239        // loud construction error, not silent physics degradation.
1240        let p1 = IkedaCarpenterParams {
1241            channel_fwhm_us: Some(0.35),
1242            ..IkedaCarpenterParams::constant(2.0, 0.005, 0.5)
1243        };
1244        let err = IkedaCarpenter::new(p1.clone(), 25.0, &SynthesisGrid::new(1.0, 100.0))
1245            .expect_err("a delta-degenerate fold must be rejected");
1246        let msg = format!("{err:?}");
1247        assert!(
1248            msg.contains("cannot resolve") && msg.contains("Increase"),
1249            "error must diagnose the cap and name remedies: {msg}"
1250        );
1251        // The per-energy synthesis (kernel_at path) refuses identically.
1252        assert!(synth_kernel(&p1, 600, 10.0).is_err());
1253
1254        // Probe 2: α = 250 ⇒ the whole prompt pulse spans 18/α ≈ 0.07 µs,
1255        // less than ONE capped step (800/8191 ≈ 0.098 µs): sampling would
1256        // step over the prompt term entirely (0.9 of the pulse weight at
1257        // R = 0.1). Must also be rejected.
1258        let p2 = IkedaCarpenterParams::constant(250.0, 0.02, 0.1);
1259        assert!(
1260            IkedaCarpenter::new(p2, 25.0, &SynthesisGrid::new(1.0, 100.0)).is_err(),
1261            "a capped step wider than the prompt core must be rejected"
1262        );
1263    }
1264
1265    #[test]
1266    fn requested_fold_is_never_a_silent_no_op() {
1267        // Adjacent to probe 1 but resolvable (β = 0.05 ⇒ capped step
1268        // 320/8191 ≈ 0.039 µs ≤ FWHM/3): when synthesis is Ok and a fold is
1269        // requested, the folded kernel must genuinely differ from the
1270        // unfolded one — by approximately the analytic fold variance
1271        // FWHM²/6 — never by ~0 (the silent delta no-op this guards against).
1272        let base = IkedaCarpenterParams::constant(2.0, 0.05, 0.5);
1273        let folded = IkedaCarpenterParams {
1274            channel_fwhm_us: Some(0.35),
1275            ..base.clone()
1276        };
1277        let (o0, w0) = synth_kernel(&base, 600, 10.0).unwrap();
1278        let (o1, w1) = synth_kernel(&folded, 600, 10.0).unwrap();
1279        let dv = kernel_variance(&o1, &w1) - kernel_variance(&o0, &w0);
1280        let expected = 0.35f64.powi(2) / 6.0;
1281        assert!(
1282            dv > 0.5 * expected,
1283            "fold variance increment {dv} µs² vs analytic {expected} µs²: \
1284             the requested fold (nearly) vanished"
1285        );
1286    }
1287
1288    #[test]
1289    fn boundary_step_fwhm_over_3_is_admitted_and_fold_survives() {
1290        // Review #645 round 2, F3: pin the exactly-admitted boundary
1291        // `dtau = FWHM/3` (rejection is STRICT: `capped_step > floor`). Per
1292        // the TRI_MIN_SAMPLES_PER_SIDE doc, each triangle side then samples
1293        // at {FWHM/3, 2FWHM/3, FWHM} — weights {2/3, 1/3, 0}: exactly 2
1294        // strictly interior nonzero samples, endpoint ON the triangle zero —
1295        // and the fold stays effective (non-delta, second moment ≈ 4FWHM²/27).
1296        //
1297        // Route to the boundary: n_tau = MIN_N_TAU = 8 with α = 1 gives a
1298        // prompt design step 18/7 ≈ 2.57 µs, far coarser than FWHM/3 for a
1299        // 1 µs triangle, so the fold refinement pins dtau to exactly
1300        // `fwhm / TRI_MIN_SAMPLES_PER_SIDE`. R = 0 keeps the cap inert
1301        // (capped step 18/8191 ≪ floor).
1302        let fwhm = 1.0;
1303        let p = IkedaCarpenterParams {
1304            channel_fwhm_us: Some(fwhm),
1305            ..IkedaCarpenterParams::constant(1.0, 0.1, 0.0)
1306        };
1307        let (offs, _) = synth_kernel(&p, MIN_N_TAU, 10.0)
1308            .expect("the dtau = FWHM/3 boundary must be admitted, not rejected");
1309        let dtau = offs[1] - offs[0];
1310        let boundary = fwhm / TRI_MIN_SAMPLES_PER_SIDE;
1311        assert!(
1312            (dtau - boundary).abs() < 1e-12,
1313            "step {dtau} µs is not the FWHM/3 boundary {boundary} µs"
1314        );
1315
1316        // The sampled triangle at the boundary: ≥ 7 samples, exactly 2
1317        // strictly interior nonzero per side, center far below the delta's 1.
1318        let tri = triangle_kernel(dtau, fwhm);
1319        assert!(tri.len() >= 7, "boundary triangle too short: {}", tri.len());
1320        let per_side = tri
1321            .iter()
1322            .take(tri.len() / 2)
1323            .filter(|&&v| v > 1e-9)
1324            .count();
1325        assert_eq!(
1326            per_side, 2,
1327            "boundary triangle must keep 2 strictly interior nonzero samples \
1328             per side: {tri:?}"
1329        );
1330        let center = tri[tri.len() / 2];
1331        assert!(
1332            center < 0.5,
1333            "center weight {center} — boundary triangle degenerated toward a delta"
1334        );
1335
1336        // Fold effectiveness: the discrete triangle's variance is 4·FWHM²/27
1337        // (samples {0, ±FWHM/3, ±2FWHM/3} with weights ∝ {1, 2/3, 1/3}),
1338        // ≈ 89 % of the analytic FWHM²/6 — the fold physics survives the
1339        // 2-interior-sample discretization.
1340        let tri_offs: Vec<f64> = (0..tri.len())
1341            .map(|i| (i as f64 - (tri.len() / 2) as f64) * dtau)
1342            .collect();
1343        let v = kernel_variance(&tri_offs, &tri);
1344        let want = 4.0 * fwhm * fwhm / 27.0;
1345        assert!(
1346            ((v - want) / want).abs() < 0.02,
1347            "boundary triangle variance {v} µs² vs discrete analytic {want} µs²"
1348        );
1349        assert!(
1350            v > 0.5 * (fwhm * fwhm / 6.0),
1351            "boundary fold variance {v} µs² collapsed below half the \
1352             continuous FWHM²/6 — fold (nearly) vanished"
1353        );
1354    }
1355
1356    #[test]
1357    fn fold_variance_matches_analytic_oracle() {
1358        // Independent analytic oracle for the fold convention (#645 F8): a
1359        // convolution adds second central moments, so the sampled kernel's
1360        // variance must grow by EXACTLY the fold kernel's analytic variance —
1361        // FWHM²/6 for the symmetric channel triangle (half-base = FWHM),
1362        // σ² for the Gaussian burst. Unlike the closed-loop calibration
1363        // tests, the expectation here comes from analysis, not from the
1364        // shared synthesis code. Pure-prompt pulse (R = 0) keeps trim /
1365        // edge-truncation error far below the increments.
1366        let base = IkedaCarpenterParams::constant(2.0, 0.1, 0.0);
1367        let (o0, w0) = synth_kernel(&base, 600, 10.0).unwrap();
1368        let v0 = kernel_variance(&o0, &w0);
1369        // Sanity: the bare Gamma(3, α) variance is 3/α².
1370        let v_gamma = 3.0 / (2.0f64 * 2.0);
1371        assert!(
1372            (v0 - v_gamma).abs() < 0.01 * v_gamma,
1373            "bare pulse variance {v0}, Gamma(3) analytic {v_gamma}"
1374        );
1375
1376        let fwhm = 0.35;
1377        let tri = IkedaCarpenterParams {
1378            channel_fwhm_us: Some(fwhm),
1379            ..base.clone()
1380        };
1381        let (o1, w1) = synth_kernel(&tri, 600, 10.0).unwrap();
1382        let dv_tri = kernel_variance(&o1, &w1) - v0;
1383        let want_tri = fwhm * fwhm / 6.0;
1384        assert!(
1385            ((dv_tri - want_tri) / want_tri).abs() < 0.02,
1386            "triangle fold added {dv_tri} µs², analytic FWHM²/6 = {want_tri} µs²"
1387        );
1388
1389        let sigma = 0.3;
1390        let gau = IkedaCarpenterParams {
1391            burst_sigma_us: Some(sigma),
1392            ..base.clone()
1393        };
1394        let (o2, w2) = synth_kernel(&gau, 600, 10.0).unwrap();
1395        let dv_gau = kernel_variance(&o2, &w2) - v0;
1396        let want_gau = sigma * sigma;
1397        assert!(
1398            ((dv_gau - want_gau) / want_gau).abs() < 0.02,
1399            "Gaussian burst added {dv_gau} µs², analytic σ² = {want_gau} µs²"
1400        );
1401    }
1402}