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//! SAMMY-RPI χ²+double-exp moderator form). Both terms are individually
37//! unit-area, so `I` is unit-area for any α,β>0 and 0≤R≤1, with first moment
38//! `⟨τ⟩ = 3/α + R/β`. The asymmetry — sharp rise, long tail toward larger τ
39//! (later TOF, lower apparent energy) — is the physical origin of the
40//! asymmetric MC kernel.
41//!
42//! ## Parameters and their energy dependence
43//!
44//! - `α(E)` [1/µs]: fast moderation/leakage rate; sets the prompt width.
45//!   Leading epithermal scaling `α ∝ √E` (Mantid `α = 1/(α₀+α₁λ)`, λ ∝ 1/√E).
46//! - `β(E)` [1/µs]: slow storage rate; sets the delayed tail. Constant by
47//!   default; optionally energy-dependent through an [`EnergyLaw`].
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 keeps its offsets on the **emission clock** (0 = pulse start), which
76//! is where the Ikeda–Carpenter function itself places them; a loaded UDR file
77//! is anchored on its peak instead and `interpolated_kernel` re-centers
78//! neither. Because the IC pulse is right-skewed, its *mean* lags its mode by
79//! ~1/α(E) in TOF, so the centroid — and even the minimum — of a broadened
80//! resonance shifts toward **lower apparent energy** by an α(E)-dependent amount
81//! (order 1e-2 eV, ~1e-3 relative, for α≈1.5 in the eV regime; larger toward
82//! lower energy). So it *does* move the broadened dip off the nominal energy,
83//! by a small amount.
84//!
85//! For the prompt-only law `α(E) = a0·√E + a1` with `R≈0`, the lag `~1/α(E)` is
86//! **exactly** the `1/√E` basis of a flight-path (`L_scale`) error *iff* `a1 = 0`:
87//! then the centroid offset scales as `c/√E`, and an `L → L(1+δ)` change shifts
88//! every TOF by `δ·K·L/√E`, also `∝ 1/√E`, so the two are degenerate. With `a1 ≠ 0`
89//! the lag `1/(a0√E + a1)` only *approximately* follows that basis (and the
90//! storage term `R/β`, negligible in the eV regime, adds a further small
91//! departure). To leading order, then, the lag is **confounded with the energy
92//! scale**, and is handled by the SHARED `(t0, L_scale)` energy scale, not by any
93//! per-family knob:
94//! - **Run-time fitting** fits the `t0`/`L_scale` energy-scale, which absorbs the
95//!   constant-`L` part of the lag exactly (same basis); only the *shape*
96//!   (skew/tail) of the asymmetry is not absorbable by position.
97//! - **Resolution calibration** (the `nereids-fitting` calibrator) **pins** the
98//!   energy scale by default — a pure shape/width fit. It can optionally fit a
99//!   SHARED `(t0, L_scale)` under a metrology prior (`with_position_prior`) for
100//!   joint energy-scale / identifiability work. A *free, per-family* position knob
101//!   is deliberately NOT used: because the lag is the same basis as `L_scale`, a
102//!   free position lets a wrong (symmetric) family imitate the asymmetric shift
103//!   and erodes the model-selection χ² (the discriminator is then only the
104//!   position-independent skew/tail). See `nereids-fitting`'s
105//!   `free_l_scale_absorbs_asymmetric_lag_and_erodes_discrimination`.
106//!
107//! `ic_centering_shifts_broadened_symmetric_dip_with_alpha` quantifies the bare
108//! mode→centroid shift; the calibrator's `fit_t0_recovers_injected_energy_scale_shift`
109//! checks the energy-scale fit recovers an injected offset. Re-centering the kernel
110//! on its centroid (a future convention change spanning the UDR path too) would
111//! remove the shift at the source.
112//!
113//! ## Optional instrument convolutions
114//!
115//! The full instrument function folds the moderator with a proton-burst
116//! (Gaussian σ) and a chopper/channel (triangle, FWHM) term. Both are optional
117//! here (`None` ⇒ omitted). Detector-time bin probabilities take the triangle
118//! only and refuse a burst. They also take a detector tail, which records a
119//! fraction of the counts late by an exponential delay; the kernel table
120//! refuses one.
121//!
122//! **Provenance of the triangle (`channel_fwhm_us`).** SAMMY broadens for the
123//! accelerator burst either as a Gaussian of FWHM `DELTAG` (SAMMY Manual R8
124//! Sec. III.C.1.a, eq. III C1 a.12) or as a square pulse of width `BURST`
125//! (Sec. III.C.2.a). At SNS the proton pulse delivered to the target is shaped
126//! by the accumulator ring (Proton Storage Ring, PSR) into an approximately
127//! triangular ~700 ns base — FWHM ≈ 350 ns — which is what the VENUS tabulated
128//! FTS kernel header records as "folded triang FWHM 350 ns PSR". NEREIDS folds
129//! that PSR triangle via `channel_fwhm_us` (symmetric triangle, half-base =
130//! FWHM, `triangle_kernel`). Note: a tabulated file whose header says the
131//! triangle is already "folded" in must NOT be double-counted against an IC
132//! model that also applies it (the `nereids-fitting` calibrator therefore
133//! applies its `psr_fwhm_ns` fold to the IC family only, never to
134//! tabulated/UDR kernels).
135
136use std::ops::RangeInclusive;
137use std::sync::Arc;
138
139use crate::resolution::{ResolutionParseError, TOF_FACTOR, TabulatedResolution};
140
141/// de Broglie wavelength factor: λ (Å) = `LAMBDA_ANGSTROM_FACTOR` / √(E in eV).
142///
143/// `λ = h/√(2·m_n·E)`; with CODATA 2018 `h`, `m_n` and the 2019-SI eV this is
144/// `0.285993 Å·√eV`. Used only by [`EnergyLaw::InverseLambda`]; its precise
145/// value folds into the fitted `α₀,α₁`, so high precision is not load-bearing.
146const LAMBDA_ANGSTROM_FACTOR: f64 = 0.285_993;
147
148/// Rates below this (1/µs) are clamped to keep the pulse well-defined.
149const MIN_RATE: f64 = 1e-9;
150
151/// The detector tail's mean delays `τ_D`, in µs, that the pulse accepts.
152pub const DETECTOR_TAIL_DECAY_US: RangeInclusive<f64> = 1e-9..=1e9;
153
154/// Relative-to-peak weight below which kernel tails are trimmed.
155const TRIM_REL: f64 = 1e-7;
156
157/// Default number of log-spaced reference energies in a synthesized table.
158pub const DEFAULT_N_ENERGIES: usize = 64;
159
160/// Default number of τ-samples spanning the prompt core of each kernel.
161pub const DEFAULT_N_TAU: usize = 600;
162
163/// Minimum accepted `n_tau` (τ-samples across the prompt core). Doubles as
164/// the module's prompt-core **resolution floor**: when [`MAX_TAU_SAMPLES`]
165/// widens the τ-step (see [`tau_geometry`]), the step may never exceed
166/// `fast_reach / (MIN_N_TAU − 1)` — the coarsest prompt sampling
167/// [`IkedaCarpenter::new`] has ever accepted as valid via its `n_tau ≥ 8`
168/// check.
169const MIN_N_TAU: usize = 8;
170
171/// Minimum samples per side SPANNING a sampled channel triangle
172/// (`dtau ≤ FWHM / 3`, half-base = FWHM). At the exactly-admitted boundary
173/// `dtau = FWHM/3` the per-side samples sit at `{FWHM/3, 2FWHM/3, FWHM}` —
174/// triangle weights `{2/3, 1/3, 0}` — so each side carries ≥ 2 strictly
175/// interior (nonzero) samples while the endpoint lands ON the triangle
176/// zero; the sampled fold is distinctly non-delta (discrete variance
177/// 4·FWHM²/27 ≈ 89 % of the analytic FWHM²/6). Coarser steps degenerate
178/// the discrete triangle toward the exact delta `[0, 1, 0]`, silently
179/// erasing a requested fold — [`tau_geometry`] rejects that instead
180/// (strictly: `capped_step > FWHM/3`).
181const TRI_MIN_SAMPLES_PER_SIDE: f64 = 3.0;
182
183const GAUSS_REACH_SIGMAS: f64 = 8.0;
184
185/// Prompt-tail reach in e-folds: the τ-grid spans `FAST_REACH_E_FOLDS / α`
186/// so the prompt Gamma(3) tail `τ²e^{−ατ}` is < ~1e-8 of peak at the edge.
187const FAST_REACH_E_FOLDS: f64 = 18.0;
188
189/// Slow/storage-tail reach in e-folds (`SLOW_REACH_E_FOLDS / β` when storage
190/// is active). Sits AT the trim horizon: `e^{−16} ≈ 1.1e-7 ≈` [`TRIM_REL`]
191/// (`ln(1/TRIM_REL) ≈ 16.1`), so the reach cannot be truncated shorter
192/// without discarding tail weight the [`TRIM_REL`] trim would keep — no
193/// hidden approximation lives in this constant.
194const SLOW_REACH_E_FOLDS: f64 = 16.0;
195
196/// Storage fractions below this are treated as "no storage tail" when sizing
197/// the τ-grid. DELIBERATELY two decades below [`TRIM_REL`], not derived from
198/// it: whether a slow tail actually survives the [`TRIM_REL`] trim depends on
199/// the α/β contrast (the trim threshold is relative to the prompt-dominated
200/// peak, and the slow term's peak weight scales with both R and β/α), so no
201/// single constant derived from [`TRIM_REL`] is exact for every admitted
202/// (α, β) — treating a larger R as absent would be a hidden approximation.
203/// The margin's only consequence is conservatism: for R ∈ (1e-9, ~1e-7) the
204/// τ-grid is sized — and the [`MAX_TAU_SAMPLES`] cap gate applied — for a
205/// slow tail whose weight the trim then discards anyway, spending samples
206/// (or rejecting a configuration) for physics that cannot appear in the
207/// kernel. It can never drop tail weight the trim would have kept.
208const R_NEGLIGIBLE: f64 = 1e-9;
209
210/// Cap on the τ-sample count spanning the PULSE BODY (`[0, τ_max]`) of one
211/// synthesized kernel. The τ-step is anchored to the prompt core and refined
212/// to resolve active folds (see [`tau_geometry`]), so a long storage tail
213/// (β ≪ α with R > 0) grows the sample COUNT rather than the step; this cap
214/// bounds that growth (CPU/memory) by widening the step to
215/// `tau_max / (MAX_TAU_SAMPLES − 1)` — but never past the resolution floor
216/// (prompt core: `fast_reach / (MIN_N_TAU − 1)`; triangle: FWHM /
217/// [`TRI_MIN_SAMPLES_PER_SIDE`]; burst: σ). A parameter/grid combination
218/// whose floor cannot be met within the cap is REJECTED loudly by
219/// [`IkedaCarpenter::new`] instead of silently under-sampled. The actual
220/// guarantee is on the pulse body only: the symmetric burst/channel fold
221/// margin (± `GAUSS_REACH_SIGMAS·σ + FWHM` at the resolved step) adds its
222/// samples ON TOP of the cap, so the final grid can exceed
223/// `MAX_TAU_SAMPLES` by the margin sample count.
224const MAX_TAU_SAMPLES: usize = 8192;
225
226#[inline]
227fn h_over_cube_taylor(u: f64) -> f64 {
228    exponential_tail_series(u, 3)
229}
230
231#[inline]
232fn exponential_tail_series(u: f64, shift: u32) -> f64 {
233    let mut sum = 0.0;
234    let mut power = 0.5;
235    for n in 0..64_u32 {
236        let next = sum + power / f64::from(n + shift);
237        if next == sum {
238            break;
239        }
240        sum = next;
241        power *= -u / f64::from(n + 1);
242    }
243    sum
244}
245
246/// Ikeda–Carpenter moderator emission density `I(τ)`.
247///
248/// `τ` in µs, rates `α,β` in 1/µs, mixing `r ∈ [0,1]`. Returns 0 for `τ < 0`.
249/// Unit-area over `τ ∈ [0,∞)`; first moment `3/α + r/β`. **NaN-free** for all
250/// `α,β > 0` (including `α ≈ β` and `β ≫ α`): the slow/storage term is evaluated
251/// from the bounded bracket `e^{−βτ} − e^{−ατ}(1+u+½u²)` (both exponentials ≤ 1
252/// for `τ,α,β > 0`, so no `e^{|u|}` overflow), falling back to a Taylor limit
253/// near `u = α−β·τ → 0` where that bracket cancels.
254#[must_use]
255pub fn ic_pulse(alpha: f64, beta: f64, r: f64, tau: f64) -> f64 {
256    // Same domain contract as ic_cdf: non-finite parameters return NaN so
257    // garbage in stays visible instead of flooring into a plausible pulse.
258    if !alpha.is_finite() || !beta.is_finite() || !r.is_finite() {
259        return f64::NAN;
260    }
261    if !tau.is_finite() || tau < 0.0 {
262        return 0.0;
263    }
264    let alpha = alpha.max(MIN_RATE);
265    let beta = beta.max(MIN_RATE);
266    let at = alpha * tau;
267    // prompt Gamma(3,α): α³τ²/2 · e^{−ατ} = (α/2)(ατ)² e^{−ατ}
268    let fast = 0.5 * alpha * at * at * (-at).exp();
269    if r <= 0.0 {
270        return fast;
271    }
272    (1.0 - r) * fast + r * storage_density(alpha, beta, tau)
273}
274
275fn storage_density(alpha: f64, beta: f64, tau: f64) -> f64 {
276    // slow/storage term = β(α/γ)³[e^{−βτ} − e^{−ατ}(1+u+½u²)], u = γτ = (α−β)τ.
277    let u = (alpha - beta) * tau;
278    let coeff = beta * alpha.powi(3) * tau.powi(3);
279    if u.abs() < 0.05 {
280        // α ≈ β: bracket/u³ → e^{−βτ}·h(u)/u³ (Taylor); avoids 0/0 cancellation.
281        coeff * (-beta * tau).exp() * h_over_cube_taylor(u)
282    } else {
283        // Bounded form: both exponentials are ≤ 1, so β ≫ α (u ≪ 0) cannot
284        // overflow (the old `e^{−βτ}·h(u)` factored an `e^{|u|}` → 0·∞ = NaN).
285        let bracket = (-beta * tau).exp() - (-alpha * tau).exp() * (1.0 + u + 0.5 * u * u);
286        coeff * bracket / (u * u * u)
287    }
288}
289
290fn ic_pulse_slope(alpha: f64, beta: f64, r: f64, tau: f64) -> f64 {
291    if !tau.is_finite() || tau < 0.0 {
292        return 0.0;
293    }
294    let alpha = alpha.max(MIN_RATE);
295    let beta = beta.max(MIN_RATE);
296    let at = alpha * tau;
297    let prompt = 0.5 * alpha * at * at * (-at).exp();
298    let prompt_slope = 0.5 * alpha * alpha * at * (2.0 - at) * (-at).exp();
299    if r <= 0.0 {
300        return prompt_slope;
301    }
302    (1.0 - r) * prompt_slope + r * beta * (prompt - storage_density(alpha, beta, tau))
303}
304
305/// Cumulative probability of the Ikeda–Carpenter moderator pulse.
306///
307/// Returns `P(U <= tau)` for moderator delay `U`. `tau` is in µs and rates
308/// are in 1/µs. The prompt term is the Gamma(3, α) cumulative distribution;
309/// the storage term is the cumulative distribution of Gamma(3, α) plus an
310/// independent exponential delay with rate β.
311///
312/// The expression is evaluated without subtracting nearly equal exponentials
313/// when `α ≈ β`. This is the bin-integral companion to [`ic_pulse`].
314///
315/// Domain contract: non-finite `alpha`, `beta`, or `r` returns NaN so garbage
316/// in stays visible; finite non-positive rates are floored to `MIN_RATE`
317/// (1e-9 µs⁻¹) and `r <= 0` disables the storage term, matching
318/// [`ic_pulse`]. A NaN `tau`
319/// returns 0 — `tau` is the integration coordinate, not a parameter, and a
320/// non-arriving coordinate contributes no mass.
321#[must_use]
322pub fn ic_cdf(alpha: f64, beta: f64, r: f64, tau: f64) -> f64 {
323    if !alpha.is_finite() || !beta.is_finite() || !r.is_finite() {
324        return f64::NAN;
325    }
326    if tau.is_nan() || tau <= 0.0 {
327        return 0.0;
328    }
329    if tau == f64::INFINITY {
330        return 1.0;
331    }
332
333    let alpha = alpha.max(MIN_RATE);
334    let beta = beta.max(MIN_RATE);
335    let at = alpha * tau;
336    let fast = gamma3_cdf(at);
337    if r <= 0.0 {
338        return fast;
339    }
340
341    // Far in the tails the general expressions overflow — `at³` or `u³`
342    // reach f64::INFINITY and produce inf·0 / inf/inf = NaN — while the
343    // limits are exact to full f64 precision, so return them directly.
344    // For at ≥ 1e100 the prompt CDF is 1 and the storage correction is
345    // exp(−βτ) with relative error ≤ 3βτ/at; for βτ ≥ 1e100 the storage
346    // delay is instantaneous and the correction is 0.
347    const CDF_TAIL_LIMIT: f64 = 1.0e100;
348    if at >= CDF_TAIL_LIMIT {
349        return (1.0 - r * (-beta * tau).exp()).clamp(0.0, 1.0);
350    }
351    if beta * tau >= CDF_TAIL_LIMIT {
352        return fast;
353    }
354
355    // The storage CDF is the prompt CDF minus the positive survival
356    // correction below. Written this way, the α=β limit is Gamma(4, α).
357    let u = (alpha - beta) * tau;
358    let correction = if u.abs() < 0.05 {
359        at.powi(3) * (-beta * tau).exp() * h_over_cube_taylor(u)
360    } else {
361        // Bounded form: neither exponential can overflow even when β >> α.
362        let bracket = (-beta * tau).exp() - (-alpha * tau).exp() * (1.0 + u + 0.5 * u * u);
363        at.powi(3) * bracket / u.powi(3)
364    };
365    (fast - r * correction).clamp(0.0, 1.0)
366}
367
368fn ic_cdf_slopes(alpha: f64, beta: f64, r: f64, tau: f64) -> [f64; 3] {
369    if !alpha.is_finite() || !beta.is_finite() || !r.is_finite() {
370        return [f64::NAN; 3];
371    }
372    if tau.is_nan() || tau <= 0.0 || tau == f64::INFINITY {
373        return [0.0; 3];
374    }
375    let at = alpha * tau;
376    let fast = 0.5 * tau * at * at * (-at).exp();
377    const CDF_TAIL_LIMIT: f64 = 1.0e100;
378    if at >= CDF_TAIL_LIMIT {
379        let storage = (-beta * tau).exp();
380        return [0.0, r * tau * storage, -storage];
381    }
382    if beta * tau >= CDF_TAIL_LIMIT {
383        return [fast, 0.0, 0.0];
384    }
385    let u = (alpha - beta) * tau;
386    let (correction, slope) = if u.abs() < 0.05 {
387        let storage = (-beta * tau).exp();
388        (
389            at.powi(3) * storage * h_over_cube_taylor(u),
390            -tau * storage * exponential_tail_series(u, 4),
391        )
392    } else {
393        let prompt = (-alpha * tau).exp();
394        let bracket = (-beta * tau).exp() - prompt * (1.0 + u + 0.5 * u * u);
395        (
396            at.powi(3) * bracket / u.powi(3),
397            tau * (prompt / (2.0 * u) - 3.0 * bracket / u.powi(4)),
398        )
399    };
400    let moved = at.powi(3) * slope;
401    [
402        fast - r * (3.0 * correction / alpha + moved),
403        r * (tau * correction + moved),
404        -correction,
405    ]
406}
407
408const GAUSS_LEGENDRE_16: [(f64, f64); 8] = [
409    (0.095_012_509_837_637_44, 0.189_450_610_455_068_59),
410    (0.281_603_550_779_258_9, 0.182_603_415_044_923_6),
411    (0.458_016_777_657_227_37, 0.169_156_519_395_002_62),
412    (0.617_876_244_402_643_8, 0.149_595_988_816_576_65),
413    (0.755_404_408_355_003_1, 0.124_628_971_255_533_95),
414    (0.865_631_202_387_831_6, 0.095_158_511_682_493),
415    (0.944_575_023_073_232_6, 0.062_253_523_938_647_61),
416    (0.989_400_934_991_649_9, 0.027_152_459_411_754_055),
417];
418
419fn fold_nodes(alpha: f64, fwhm_us: f64, delay_us: f64, mut node: impl FnMut(f64, f64)) {
420    let end = delay_us.min(fwhm_us);
421    let finest = 2.0 / alpha.max(MIN_RATE);
422    for (lo, hi) in [(-fwhm_us, end.min(0.0)), (0.0, end)] {
423        let (mut right, mut width) = (hi, finest);
424        while right > lo {
425            let left = (right - width).max(lo);
426            let (centre, half) = (0.5 * (left + right), 0.5 * (right - left));
427            for &(position, weight) in &GAUSS_LEGENDRE_16 {
428                for s in [centre - half * position, centre + half * position] {
429                    node(s, half * weight);
430                }
431            }
432            right = left;
433            width *= 2.0;
434        }
435    }
436}
437
438fn triangle_fold<const N: usize>(
439    alpha: f64,
440    fwhm_us: f64,
441    delay_us: f64,
442    g: impl Fn(f64) -> [f64; N],
443) -> [f64; N] {
444    let mut total = [0.0; N];
445    fold_nodes(alpha, fwhm_us, delay_us, |s, weight| {
446        for (sum, value) in total.iter_mut().zip(g(delay_us - s)) {
447            *sum += weight * (fwhm_us - s.abs()) / (fwhm_us * fwhm_us) * value;
448        }
449    });
450    total
451}
452
453fn folded_slopes(pulse: &Recorded, fwhm_us: f64, delay_us: f64) -> [f64; 4] {
454    let mut slopes = [0.0; 4];
455    fold_nodes(pulse.alpha, fwhm_us, delay_us, |s, weight| {
456        let x = delay_us - s;
457        let t = (s / fwhm_us).abs();
458        let triangle = weight * (1.0 - t) / fwhm_us;
459        let values = pulse.slopes(x);
460        for (slope, value) in slopes.iter_mut().zip(&values[..3]) {
461            *slope += triangle * value;
462        }
463        let moment = (1.0 / 6.0 - 0.5 * t * t + t.powi(3) / 3.0) / (2.0 * fwhm_us);
464        slopes[3] += weight * moment * values[3];
465    });
466    slopes
467}
468
469/// The pulse at one energy as the detector records it: rates `α` and `β`,
470/// storage fraction `R`, and the detector tail's fraction `f` and rate
471/// `1/τ_D`.
472#[derive(Debug, Clone, Copy)]
473struct Recorded {
474    alpha: f64,
475    beta: f64,
476    r: f64,
477    tail: Option<(f64, f64)>,
478}
479
480impl Recorded {
481    fn delayed(&self) -> Option<(f64, f64)> {
482        self.tail.filter(|&(fraction, _)| fraction != 0.0)
483    }
484
485    fn cdf(&self, tau: f64) -> f64 {
486        let plain = ic_cdf(self.alpha, self.beta, self.r, tau);
487        match self.delayed() {
488            None => plain,
489            Some((f, rate)) => ((1.0 - f) * plain + f * self.late(rate, tau)[0]).clamp(0.0, 1.0),
490        }
491    }
492
493    fn density(&self, tau: f64) -> f64 {
494        let plain = ic_pulse(self.alpha, self.beta, self.r, tau);
495        match self.delayed() {
496            None => plain,
497            Some((f, rate)) => (1.0 - f) * plain + f * self.late(rate, tau)[5],
498        }
499    }
500
501    /// `[∂/∂α, ∂/∂β, ∂/∂R]` of [`Self::cdf`], and the slope of
502    /// [`Self::density`].
503    fn slopes(&self, tau: f64) -> [f64; 4] {
504        let [a, b, c] = ic_cdf_slopes(self.alpha, self.beta, self.r, tau);
505        let plain = [a, b, c, ic_pulse_slope(self.alpha, self.beta, self.r, tau)];
506        match self.delayed() {
507            None => plain,
508            Some((f, rate)) => {
509                let late = self.late(rate, tau);
510                let late = [late[1], late[2], late[3], late[6]];
511                std::array::from_fn(|n| (1.0 - f) * plain[n] + f * late[n])
512            }
513        }
514    }
515
516    /// `[∂/∂f, ∂/∂τ_D]` of [`Self::cdf`], 0 without a tail.
517    fn tail_slopes(&self, tau: f64) -> [f64; 2] {
518        let Some((f, rate)) = self.tail else {
519            return [0.0; 2];
520        };
521        let late = self.late(rate, tau);
522        [
523            late[0] - ic_cdf(self.alpha, self.beta, self.r, tau),
524            -f * rate * rate * late[4],
525        ]
526    }
527
528    fn late(&self, rate: f64, tau: f64) -> [f64; 7] {
529        delayed(self.alpha, self.beta, self.r, rate, tau)
530    }
531}
532
533/// The relative distance from `β` within which [`delayed`] interpolates in
534/// the tail's rate.
535const NEAR_RATE: f64 = 1e-3;
536
537/// The pulse delayed by an exponential of rate `rate`, at delay `tau`:
538/// `[G, ∂G/∂α, ∂G/∂β, ∂G/∂R, ∂G/∂rate, G′, G″]` with
539/// `G = S_λ + Rλ(S_β − S_λ)/(λ − β)`, `λ` the rate and `S_x` the cumulative
540/// probability of Gamma(3, α) plus an exponential delay of rate `x`.  Within
541/// [`NEAR_RATE`] of `β`, each is the cubic through its values at the rates
542/// `β(1 ± NEAR_RATE)` and `β(1 ± 2·NEAR_RATE)`.
543fn delayed(alpha: f64, beta: f64, r: f64, rate: f64, tau: f64) -> [f64; 7] {
544    let gap = (rate - beta) / (NEAR_RATE * beta);
545    if gap.abs() >= 1.0 {
546        return delayed_apart(alpha, beta, r, rate, tau);
547    }
548    let mut total = [0.0; 7];
549    for (node, weight) in [
550        (-2.0, (gap * gap - 1.0) * (gap - 2.0) / -12.0),
551        (-1.0, (gap + 2.0) * (gap - 1.0) * (gap - 2.0) / 6.0),
552        (1.0, (gap + 2.0) * (gap + 1.0) * (gap - 2.0) / -6.0),
553        (2.0, (gap + 2.0) * (gap * gap - 1.0) / 12.0),
554    ] {
555        let values = delayed_apart(alpha, beta, r, beta * (1.0 + node * NEAR_RATE), tau);
556        for (sum, value) in total.iter_mut().zip(values) {
557            *sum += weight * value;
558        }
559    }
560    total
561}
562
563fn delayed_apart(alpha: f64, beta: f64, r: f64, rate: f64, tau: f64) -> [f64; 7] {
564    if tau.is_nan() || tau <= 0.0 {
565        return [0.0; 7];
566    }
567    if tau == f64::INFINITY {
568        return [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
569    }
570    let alpha = alpha.max(MIN_RATE);
571    let at = alpha * tau;
572    let (whole, prompt) = (gamma3_cdf(at), 0.5 * alpha * at * at * (-at).exp());
573    let storage = |x: f64| {
574        let [by_alpha, by_rate, lost] = ic_cdf_slopes(alpha, x, 1.0, tau);
575        let density = -x * lost;
576        [
577            whole + lost,
578            by_alpha,
579            by_rate,
580            density,
581            x * (prompt - density),
582        ]
583    };
584    let (at_beta, at_rate) = (storage(beta), storage(rate));
585    let gap = rate - beta;
586    let apart: [f64; 5] = std::array::from_fn(|n| (at_beta[n] - at_rate[n]) / gap);
587    [
588        at_rate[0] + r * rate * apart[0],
589        at_rate[1] + r * rate * apart[1],
590        r * rate * (at_beta[2] + apart[0]) / gap,
591        rate * apart[0],
592        at_rate[2] + r * apart[0] - r * rate * (at_rate[2] + apart[0]) / gap,
593        at_rate[3] + r * rate * apart[3],
594        at_rate[4] + r * rate * apart[4],
595    ]
596}
597
598/// Each bin's difference of `g` between its upper and lower edge, `g` taken
599/// at the edge's delay after `arrival_us`.
600fn bin_differences<const N: usize>(
601    edges_us: &[f64],
602    arrival_us: f64,
603    g: impl Fn(f64) -> [f64; N],
604) -> [Vec<f64>; N] {
605    let at_edges: Vec<[f64; N]> = edges_us.iter().map(|edge| g(edge - arrival_us)).collect();
606    std::array::from_fn(|n| at_edges.windows(2).map(|w| w[1][n] - w[0][n]).collect())
607}
608
609/// Gamma(3, rate=1) cumulative distribution at dimensionless time `x`.
610fn gamma3_cdf(x: f64) -> f64 {
611    if x <= 0.0 {
612        return 0.0;
613    }
614    // Beyond x ≈ 750 the survival term exp(−x)·(1+x+x²/2) underflows to an
615    // exact zero long before the polynomial can overflow (x² reaches
616    // f64::INFINITY only at x ~ 1.3e154, where 0·inf would be NaN).
617    if x >= 750.0 {
618        return 1.0;
619    }
620    if x < 0.5 {
621        // Integral of x² exp(-x) / 2 as an alternating series. The direct
622        // `1 - exp(-x)(1+x+x²/2)` loses most digits for small x.
623        let mut n = 0_u32;
624        let mut term = x.powi(3) / 6.0;
625        let mut sum = term;
626        loop {
627            let n_f = f64::from(n);
628            term *= -x * (n_f + 3.0) / ((n_f + 1.0) * (n_f + 4.0));
629            let next = sum + term;
630            n += 1;
631            if next == sum || n >= 128 {
632                return next.clamp(0.0, 1.0);
633            }
634            sum = next;
635        }
636    }
637    (1.0 - (-x).exp() * (1.0 + x + 0.5 * x * x)).clamp(0.0, 1.0)
638}
639
640/// Energy-dependence law for an Ikeda–Carpenter parameter.
641///
642/// A small closed set so the *fixed-or-fit* cases share one representation:
643/// fixed ⇒ [`Const`](EnergyLaw::Const); fit ⇒ a parametric law whose
644/// coefficients are the fit variables.
645#[derive(Debug, Clone, PartialEq)]
646pub enum EnergyLaw {
647    /// Energy-independent constant.
648    Const(f64),
649    /// `a0·√(E[eV]) + a1` — leading epithermal scaling of the fast rate `α(E)`.
650    SqrtE { a0: f64, a1: f64 },
651    /// Mantid IC form `1/(a0 + a1·λ)`, λ (Å) = `LAMBDA_ANGSTROM_FACTOR`/√E.
652    /// Behaves as `α ∝ √E` at low E and saturates to `1/a0` at high E.
653    InverseLambda { a0: f64, a1: f64 },
654    /// `exp(−E[meV]/kappa)` — storage fraction `R(E)`, → 0 in the eV regime.
655    ExpMilliEv { kappa: f64 },
656    /// Linear in `√E` from `low` at `span_ev.0` to `high` at `span_ev.1` eV,
657    /// and between `low` and `high` at every energy.
658    SqrtEBetween {
659        low: f64,
660        high: f64,
661        span_ev: (f64, f64),
662    },
663}
664
665impl EnergyLaw {
666    /// Evaluate the law at `energy_ev` (eV). Non-positive energy yields the
667    /// `E→0` limit where well-defined (and a clamped value otherwise).
668    #[must_use]
669    pub fn eval(&self, energy_ev: f64) -> f64 {
670        let e = energy_ev.max(0.0);
671        match *self {
672            EnergyLaw::Const(c) => c,
673            EnergyLaw::SqrtE { a0, a1 } => a0 * e.sqrt() + a1,
674            EnergyLaw::InverseLambda { a0, a1 } => {
675                let denom = inverse_lambda_denom(a0, a1, e);
676                if denom.abs() < MIN_RATE {
677                    1.0 / MIN_RATE
678                } else {
679                    1.0 / denom
680                }
681            }
682            EnergyLaw::ExpMilliEv { kappa } => {
683                if kappa.abs() < MIN_RATE {
684                    0.0
685                } else {
686                    (-(e * 1000.0) / kappa).exp()
687                }
688            }
689            EnergyLaw::SqrtEBetween {
690                low,
691                high,
692                span_ev: (e_low, e_high),
693            } => {
694                let share = (e.sqrt() - e_low.sqrt()) / (e_high.sqrt() - e_low.sqrt());
695                (low + (high - low) * share).clamp(low.min(high), low.max(high))
696            }
697        }
698    }
699
700    /// True when the law is numerically singular at `energy_ev`: the raw
701    /// `InverseLambda` denominator lies inside the ±[`MIN_RATE`] window that
702    /// [`EnergyLaw::eval`] floors away. The floor keeps a fit trial step from
703    /// dividing by zero mid-optimization, but a *configured* law inside that
704    /// window is a mathematically undefined rate, not a large one — without
705    /// this check the floor converts an undefined (or tiny-negative)
706    /// denominator into a plausible huge positive rate before the range
707    /// validation below can see it.
708    #[must_use]
709    pub(crate) fn is_singular_at(&self, energy_ev: f64) -> bool {
710        match *self {
711            EnergyLaw::InverseLambda { a0, a1 } => {
712                inverse_lambda_denom(a0, a1, energy_ev.max(0.0)).abs() < MIN_RATE
713            }
714            // κ = 0 is undefined and a tiny NEGATIVE κ is a divergent law
715            // (exp(+E/|κ|)); eval's floor maps both to 0.0, which is the
716            // legitimate κ → 0⁺ limit only for positive κ.
717            EnergyLaw::ExpMilliEv { kappa } => (-MIN_RATE..=0.0).contains(&kappa),
718            _ => false,
719        }
720    }
721}
722
723/// Raw `InverseLambda` denominator `a0 + a1·λ(E)` — shared by [`EnergyLaw::eval`]
724/// and the singularity check so the two can never disagree on the window.
725fn inverse_lambda_denom(a0: f64, a1: f64, e: f64) -> f64 {
726    let lambda = if e > 0.0 {
727        LAMBDA_ANGSTROM_FACTOR / e.sqrt()
728    } else {
729        f64::INFINITY
730    };
731    a0 + a1 * lambda
732}
733
734/// Parameters of the Ikeda–Carpenter resolution model.
735#[derive(Debug, Clone, PartialEq)]
736pub struct IkedaCarpenterParams {
737    /// Fast (slowing-down) rate `α(E)`, 1/µs. Must evaluate to at least
738    /// 1e-9.
739    pub alpha: EnergyLaw,
740    /// Slow (storage) rate `β(E)`, 1/µs. Must evaluate to at least 1e-9.
741    pub beta: EnergyLaw,
742    /// Storage mixing fraction `R(E)`, 0 ≤ R ≤ 1.
743    pub r: EnergyLaw,
744    /// Optional proton-burst Gaussian standard deviation (µs); refused in
745    /// detector time.
746    pub burst_sigma_us: Option<f64>,
747    /// Optional chopper/channel triangle FWHM (µs).
748    pub channel_fwhm_us: Option<f64>,
749    /// Optional detector tail; detector time only, refused by the kernel
750    /// table.
751    pub detector_tail: Option<DetectorTail>,
752}
753
754/// A detector that records a fraction `f` of the counts late by an
755/// exponential delay of mean `τ_D`, and the rest at their arrival.
756#[derive(Debug, Clone, Copy, PartialEq)]
757pub struct DetectorTail {
758    /// `f`, within 0–1.
759    pub fraction: f64,
760    /// `τ_D` in µs, from 1e-9 to 1e9.
761    pub decay_us: f64,
762}
763
764impl IkedaCarpenterParams {
765    /// A pure-moderator parameter set (no burst, no channel) with constant
766    /// rates — the simplest case, useful for fixed-parameter or unit-test use.
767    #[must_use]
768    pub fn constant(alpha: f64, beta: f64, r: f64) -> Self {
769        Self {
770            alpha: EnergyLaw::Const(alpha),
771            beta: EnergyLaw::Const(beta),
772            r: EnergyLaw::Const(r),
773            burst_sigma_us: None,
774            channel_fwhm_us: None,
775            detector_tail: None,
776        }
777    }
778
779    fn tail_rate(&self) -> Result<Option<(f64, f64)>, ResolutionParseError> {
780        let Some(tail) = self.detector_tail else {
781            return Ok(None);
782        };
783        if !((0.0..=1.0).contains(&tail.fraction)
784            && DETECTOR_TAIL_DECAY_US.contains(&tail.decay_us))
785        {
786            return Err(ResolutionParseError::InvalidFormat(format!(
787                "a detector tail needs a fraction within 0–1 and a decay from {:e} to {:e} µs, \
788                 got {tail:?}",
789                DETECTOR_TAIL_DECAY_US.start(),
790                DETECTOR_TAIL_DECAY_US.end()
791            )));
792        }
793        Ok(Some((tail.fraction, 1.0 / tail.decay_us)))
794    }
795
796    fn recorded_at(&self, energy_ev: f64) -> Result<Recorded, ResolutionParseError> {
797        self.validate_probe_energy(energy_ev)?;
798        Ok(Recorded {
799            alpha: self.alpha.eval(energy_ev),
800            beta: self.beta.eval(energy_ev),
801            r: self.r.eval(energy_ev),
802            tail: self.tail_rate()?,
803        })
804    }
805
806    fn triangle_fwhm_us(&self) -> Result<f64, ResolutionParseError> {
807        if self.burst_sigma_us.unwrap_or(0.0) != 0.0 {
808            return Err(ResolutionParseError::InvalidFormat(
809                "a Gaussian burst is not supported in detector time; model the proton \
810                 pulse with channel_fwhm_us"
811                    .to_string(),
812            ));
813        }
814        let fwhm_us = self.channel_fwhm_us.unwrap_or(0.0);
815        if !(fwhm_us.is_finite() && fwhm_us >= 0.0) {
816            return Err(ResolutionParseError::InvalidFormat(format!(
817                "the triangle's FWHM must be finite and 0 or more, got {fwhm_us} µs"
818            )));
819        }
820        Ok(fwhm_us)
821    }
822
823    /// The first and last delay, in µs after the nominal arrival, of a
824    /// neutron of `energy_ev`, outside which [`IkedaCarpenter::detector_bin_probabilities`]
825    /// gives it less than [`NEGLIGIBLE_ARRIVAL_PROBABILITY`] chance of
826    /// arriving: the triangle's half-base before 0, and that half-base after
827    /// the delay where the chance of a later record without the triangle
828    /// falls to that chance.
829    ///
830    /// # Errors
831    /// [`ResolutionParseError::InvalidFormat`] when `energy_ev` is not
832    /// positive and finite, a law is singular or out of range there, the
833    /// pulse has a Gaussian burst, or its detector tail is out of range.
834    pub fn delays_us(&self, energy_ev: f64) -> Result<(f64, f64), ResolutionParseError> {
835        let pulse = self.recorded_at(energy_ev)?;
836        let h = self.triangle_fwhm_us()?;
837        Ok((-h, tail_delay(&pulse) + h))
838    }
839
840    /// Probability that a neutron of `true_energy_ev` whose nominal arrival,
841    /// before the pulse's delay, is `arrival_us` is recorded in each bin of
842    /// `detector_time_edges_us`; as [`IkedaCarpenter::detector_bin_probabilities`].
843    ///
844    /// # Errors
845    /// As [`IkedaCarpenter::detector_bin_probabilities`], with `arrival_us` finite.
846    pub fn bin_probabilities_at(
847        &self,
848        true_energy_ev: f64,
849        arrival_us: f64,
850        detector_time_edges_us: &[f64],
851    ) -> Result<Vec<f64>, ResolutionParseError> {
852        let (pulse, h) = self.at_arrival(true_energy_ev, arrival_us, detector_time_edges_us)?;
853        let cdf = |x: f64| {
854            if h == 0.0 {
855                pulse.cdf(x)
856            } else {
857                triangle_fold(pulse.alpha, h, x, |tau| [pulse.cdf(tau)])[0].clamp(0.0, 1.0)
858            }
859        };
860        let cdfs: Vec<f64> = detector_time_edges_us
861            .iter()
862            .map(|edge| cdf(edge - arrival_us))
863            .collect();
864        Ok(cdfs.windows(2).map(|w| (w[1] - w[0]).max(0.0)).collect())
865    }
866
867    /// The derivative of each of [`Self::bin_probabilities_at`] with respect
868    /// to `arrival_us`, per µs: the pulse density, folded with the triangle,
869    /// at the bin's lower edge minus that at its upper edge.
870    ///
871    /// # Errors
872    /// As [`Self::bin_probabilities_at`].
873    pub fn bin_arrival_slopes_at(
874        &self,
875        true_energy_ev: f64,
876        arrival_us: f64,
877        detector_time_edges_us: &[f64],
878    ) -> Result<Vec<f64>, ResolutionParseError> {
879        let (pulse, h) = self.at_arrival(true_energy_ev, arrival_us, detector_time_edges_us)?;
880        let density = |x: f64| {
881            if h == 0.0 {
882                pulse.density(x)
883            } else {
884                triangle_fold(pulse.alpha, h, x, |tau| [pulse.density(tau)])[0]
885            }
886        };
887        let densities: Vec<f64> = detector_time_edges_us
888            .iter()
889            .map(|edge| density(edge - arrival_us))
890            .collect();
891        Ok(densities.windows(2).map(|w| w[0] - w[1]).collect())
892    }
893
894    /// The derivatives of each of [`Self::bin_probabilities_at`] with
895    /// respect to `α`, `β`, `R` and the square of the triangle's FWHM `h`,
896    /// each evaluated at `true_energy_ev`: `[∂P/∂α, ∂P/∂β, ∂P/∂R, ∂P/∂h²]`,
897    /// one value per bin.  The probabilities are even in `h` and smooth in
898    /// `h²`, so `∂P/∂h²` is finite and generally not 0 at `h = 0`.
899    ///
900    /// # Errors
901    /// As [`Self::bin_probabilities_at`].
902    pub fn bin_pulse_slopes_at(
903        &self,
904        true_energy_ev: f64,
905        arrival_us: f64,
906        detector_time_edges_us: &[f64],
907    ) -> Result<[Vec<f64>; 4], ResolutionParseError> {
908        let (pulse, h) = self.at_arrival(true_energy_ev, arrival_us, detector_time_edges_us)?;
909        let slopes = |x: f64| {
910            if h == 0.0 {
911                let [a, b, c, d] = pulse.slopes(x);
912                [a, b, c, d / 12.0]
913            } else {
914                folded_slopes(&pulse, h, x)
915            }
916        };
917        Ok(bin_differences(detector_time_edges_us, arrival_us, slopes))
918    }
919
920    /// The derivatives of each of [`Self::bin_probabilities_at`] with
921    /// respect to the detector tail's `f` and `τ_D`, each per its unit:
922    /// `[∂P/∂f, ∂P/∂τ_D]`, one value per bin.
923    ///
924    /// # Errors
925    /// As [`Self::bin_probabilities_at`], and
926    /// [`ResolutionParseError::InvalidFormat`] for a pulse without a
927    /// detector tail.
928    pub fn bin_tail_slopes_at(
929        &self,
930        true_energy_ev: f64,
931        arrival_us: f64,
932        detector_time_edges_us: &[f64],
933    ) -> Result<[Vec<f64>; 2], ResolutionParseError> {
934        let (pulse, h) = self.at_arrival(true_energy_ev, arrival_us, detector_time_edges_us)?;
935        if pulse.tail.is_none() {
936            return Err(ResolutionParseError::InvalidFormat(
937                "the pulse has no detector tail".to_string(),
938            ));
939        }
940        let slopes = |x: f64| {
941            if h == 0.0 {
942                pulse.tail_slopes(x)
943            } else {
944                triangle_fold(pulse.alpha, h, x, |tau| pulse.tail_slopes(tau))
945            }
946        };
947        Ok(bin_differences(detector_time_edges_us, arrival_us, slopes))
948    }
949
950    fn at_arrival(
951        &self,
952        true_energy_ev: f64,
953        arrival_us: f64,
954        detector_time_edges_us: &[f64],
955    ) -> Result<(Recorded, f64), ResolutionParseError> {
956        let pulse = self.recorded_at(true_energy_ev)?;
957        if !arrival_us.is_finite() {
958            return Err(ResolutionParseError::InvalidFormat(format!(
959                "the nominal arrival must be finite, got {arrival_us} µs"
960            )));
961        }
962        if detector_time_edges_us.len() < 2
963            || detector_time_edges_us.iter().any(|x| !x.is_finite())
964            || detector_time_edges_us.windows(2).any(|w| w[0] >= w[1])
965        {
966            return Err(ResolutionParseError::InvalidFormat(
967                "detector time edges must contain at least two finite, strictly increasing values"
968                    .to_string(),
969            ));
970        }
971        Ok((pulse, self.triangle_fwhm_us()?))
972    }
973
974    fn validate_probe_energy(&self, energy_ev: f64) -> Result<(), ResolutionParseError> {
975        if !energy_ev.is_finite() || energy_ev <= 0.0 {
976            return Err(ResolutionParseError::InvalidFormat(format!(
977                "true energy must be positive and finite, got {energy_ev}"
978            )));
979        }
980        for (name, law) in [("alpha", &self.alpha), ("beta", &self.beta), ("R", &self.r)] {
981            if law.is_singular_at(energy_ev) {
982                return Err(ResolutionParseError::InvalidFormat(format!(
983                    "Ikeda–Carpenter {name}({energy_ev}) law is singular (an \
984                     InverseLambda denominator within ±{MIN_RATE} of zero, \
985                     or an ExpMilliEv κ in [−{MIN_RATE}, 0])"
986                )));
987            }
988        }
989        let alpha = self.alpha.eval(energy_ev);
990        let beta = self.beta.eval(energy_ev);
991        let r = self.r.eval(energy_ev);
992        if !alpha.is_finite() || alpha < MIN_RATE {
993            return Err(ResolutionParseError::InvalidFormat(format!(
994                "Ikeda–Carpenter alpha({energy_ev}) must be finite and at least {MIN_RATE} \
995                 µs⁻¹, got {alpha}"
996            )));
997        }
998        if !beta.is_finite() || beta < MIN_RATE {
999            return Err(ResolutionParseError::InvalidFormat(format!(
1000                "Ikeda–Carpenter beta({energy_ev}) must be finite and at least {MIN_RATE} \
1001                 µs⁻¹, got {beta}"
1002            )));
1003        }
1004        if !r.is_finite() || !(0.0..=1.0).contains(&r) {
1005            return Err(ResolutionParseError::InvalidFormat(format!(
1006                "Ikeda–Carpenter R({energy_ev}) must be in [0, 1], got {r}"
1007            )));
1008        }
1009        Ok(())
1010    }
1011}
1012
1013/// Configuration of the energy / time grids used to synthesize the IC kernel
1014/// table. The energy grid spans the data range densely enough that interref
1015/// interpolation error is negligible.
1016#[derive(Debug, Clone)]
1017pub struct SynthesisGrid {
1018    /// Lowest reference energy (eV), > 0.
1019    pub e_min_ev: f64,
1020    /// Highest reference energy (eV), > `e_min_ev`.
1021    pub e_max_ev: f64,
1022    /// Number of log-spaced reference energies (≥ 2).
1023    pub n_energies: usize,
1024    /// Number of τ-samples spanning the prompt core of each kernel
1025    /// (≥ [`MIN_N_TAU`](crate::ikeda_carpenter) = 8). A long storage tail
1026    /// (β ≪ α with R > 0) grows the per-kernel sample count beyond `n_tau`;
1027    /// that count is capped at 8192 (`MAX_TAU_SAMPLES`), past which the
1028    /// τ-step widens — never below the resolution floor (prompt core at the
1029    /// `n_tau = 8` density, folds at ≥ 3 triangle samples per side / ≥ 1
1030    /// sample per burst σ): a combination that cannot be resolved within the
1031    /// cap is rejected by [`IkedaCarpenter::new`]. Active burst/channel folds
1032    /// add their ±(`GAUSS_REACH_SIGMAS`·σ + FWHM) margin samples on top of
1033    /// the cap.
1034    pub n_tau: usize,
1035}
1036
1037impl SynthesisGrid {
1038    /// A sensible default grid: `[e_min, e_max]` log-spaced over
1039    /// [`DEFAULT_N_ENERGIES`] points with [`DEFAULT_N_TAU`] τ-samples.
1040    #[must_use]
1041    pub fn new(e_min_ev: f64, e_max_ev: f64) -> Self {
1042        Self {
1043            e_min_ev,
1044            e_max_ev,
1045            n_energies: DEFAULT_N_ENERGIES,
1046            n_tau: DEFAULT_N_TAU,
1047        }
1048    }
1049}
1050
1051/// Analytical Ikeda–Carpenter resolution model.
1052///
1053/// Synthesizes a [`TabulatedResolution`] at construction and applies it through
1054/// the same broadening path as a Monte-Carlo file. Cloning is cheap (the
1055/// synthesized table is the only large field). Re-synthesize (construct anew)
1056/// when fitting changes the parameters.
1057#[derive(Debug, Clone)]
1058pub struct IkedaCarpenter {
1059    params: IkedaCarpenterParams,
1060    flight_path_m: f64,
1061    /// Shared: rebinding the flight path must not copy it.
1062    ref_energies: Arc<Vec<f64>>,
1063    n_tau: usize,
1064    tabulated: TabulatedResolution,
1065}
1066
1067impl IkedaCarpenter {
1068    /// Build the model, synthesizing its kernel table over `grid`.
1069    ///
1070    /// # Errors
1071    /// Returns [`ResolutionParseError::InvalidFormat`] for a non-positive
1072    /// flight path, a degenerate grid (`n_energies < 2`, `n_tau < 8`,
1073    /// `e_min ≤ 0`, `e_max ≤ e_min`), an `α(E)` or `β(E)` below 1e-9 µs⁻¹ at a
1074    /// reference energy, a parameter/grid
1075    /// combination whose τ-grid cannot resolve the prompt core and requested
1076    /// folds within the `MAX_TAU_SAMPLES` cap at some reference energy (see
1077    /// `tau_geometry` — remedy: larger `β`, `R = 0`, or a wider/disabled
1078    /// fold), a detector tail, or if the synthesized kernels fail
1079    /// [`TabulatedResolution::from_kernels`] validation.
1080    pub fn new(
1081        params: IkedaCarpenterParams,
1082        flight_path_m: f64,
1083        grid: &SynthesisGrid,
1084    ) -> Result<Self, ResolutionParseError> {
1085        if !flight_path_m.is_finite() || flight_path_m <= 0.0 {
1086            return Err(ResolutionParseError::InvalidFormat(format!(
1087                "Flight path must be a positive finite number, got {flight_path_m}"
1088            )));
1089        }
1090        if grid.n_energies < 2 {
1091            return Err(ResolutionParseError::InvalidFormat(format!(
1092                "n_energies must be >= 2, got {}",
1093                grid.n_energies
1094            )));
1095        }
1096        if grid.n_tau < MIN_N_TAU {
1097            return Err(ResolutionParseError::InvalidFormat(format!(
1098                "n_tau must be >= {MIN_N_TAU}, got {}",
1099                grid.n_tau
1100            )));
1101        }
1102        if !grid.e_min_ev.is_finite()
1103            || !grid.e_max_ev.is_finite()
1104            || grid.e_min_ev <= 0.0
1105            || grid.e_max_ev <= grid.e_min_ev
1106        {
1107            return Err(ResolutionParseError::InvalidFormat(format!(
1108                "Require finite 0 < e_min < e_max, got [{}, {}]",
1109                grid.e_min_ev, grid.e_max_ev
1110            )));
1111        }
1112        let ln_lo = grid.e_min_ev.ln();
1113        let ln_hi = grid.e_max_ev.ln();
1114        let denom = (grid.n_energies - 1) as f64;
1115        let ref_energies: Vec<f64> = (0..grid.n_energies)
1116            .map(|i| (ln_lo + (i as f64 / denom) * (ln_hi - ln_lo)).exp())
1117            .collect();
1118
1119        // Reject singular rate laws before the range checks: a near-zero
1120        // InverseLambda denominator is an undefined configuration, and eval's
1121        // numerical floor would otherwise convert it into a plausible huge
1122        // positive rate that the α > 0 / β > 0 checks below cannot distinguish
1123        // from genuine physics.
1124        for (name, law) in [("α", &params.alpha), ("β", &params.beta), ("R", &params.r)] {
1125            if let Some(&bad) = ref_energies.iter().find(|&&e| law.is_singular_at(e)) {
1126                return Err(ResolutionParseError::InvalidFormat(format!(
1127                    "Ikeda–Carpenter {name}(E) law is singular at E = {bad} eV \
1128                     (an InverseLambda denominator within \
1129                     ±{MIN_RATE} of zero, or an ExpMilliEv κ in [−{MIN_RATE}, 0])"
1130                )));
1131            }
1132        }
1133        if let Some(&bad) = ref_energies.iter().find(|&&e| {
1134            let a = params.alpha.eval(e);
1135            !a.is_finite() || a < MIN_RATE
1136        }) {
1137            return Err(ResolutionParseError::InvalidFormat(format!(
1138                "Ikeda–Carpenter α(E) must be at least {MIN_RATE} µs⁻¹, but α({bad}) = {} is not",
1139                params.alpha.eval(bad)
1140            )));
1141        }
1142        // β is a rate, so every synthesized reference energy must give a
1143        // positive finite value. Reject invalid laws instead of clamping them
1144        // into a different pulse.
1145        if let Some(&bad) = ref_energies.iter().find(|&&e| {
1146            let beta = params.beta.eval(e);
1147            !beta.is_finite() || beta < MIN_RATE
1148        }) {
1149            return Err(ResolutionParseError::InvalidFormat(format!(
1150                "Ikeda–Carpenter β(E) must be at least {MIN_RATE} µs⁻¹, but β({bad}) = {} is not",
1151                params.beta.eval(bad)
1152            )));
1153        }
1154        // Reject storage-fraction laws that fall outside [0, 1] (synthesis clamps
1155        // R, masking a mis-specified law); a physical mixing fraction is in [0,1].
1156        if let Some(&bad) = ref_energies.iter().find(|&&e| {
1157            let r = params.r.eval(e);
1158            !r.is_finite() || !(0.0..=1.0).contains(&r)
1159        }) {
1160            return Err(ResolutionParseError::InvalidFormat(format!(
1161                "Ikeda–Carpenter R(E) must be in [0, 1], but R({bad}) = {} is not",
1162                params.r.eval(bad)
1163            )));
1164        }
1165        // Reject invalid optional instrument-convolution widths up front; synthesis
1166        // otherwise masks a negative width via `.abs()` and silently swallows a NaN.
1167        for (name, v) in [
1168            ("burst_sigma_us", params.burst_sigma_us),
1169            ("channel_fwhm_us", params.channel_fwhm_us),
1170        ] {
1171            if let Some(x) = v
1172                && (!x.is_finite() || x < 0.0)
1173            {
1174                return Err(ResolutionParseError::InvalidFormat(format!(
1175                    "Ikeda–Carpenter {name} must be finite and >= 0, got {x}"
1176                )));
1177            }
1178        }
1179        if params.detector_tail.is_some() {
1180            return Err(ResolutionParseError::InvalidFormat(
1181                "a detector tail is not supported in the kernel table".to_string(),
1182            ));
1183        }
1184
1185        // Synthesis is fallible: a kernel whose τ-grid cannot resolve the
1186        // requested physics within MAX_TAU_SAMPLES (long slow tail vs a fine
1187        // fold / fast prompt core) errs loudly here instead of silently
1188        // degrading — see `tau_geometry`.
1189        let kernels: Vec<(Vec<f64>, Vec<f64>)> = ref_energies
1190            .iter()
1191            .map(|&e| synth_kernel(&params, grid.n_tau, e))
1192            .collect::<Result<_, _>>()?;
1193
1194        let tabulated =
1195            TabulatedResolution::from_kernels(ref_energies.clone(), kernels, flight_path_m)?;
1196
1197        Ok(Self {
1198            params,
1199            flight_path_m,
1200            ref_energies: Arc::new(ref_energies),
1201            n_tau: grid.n_tau,
1202            tabulated,
1203        })
1204    }
1205
1206    /// The synthesized tabulated kernel set (the broadening engine).
1207    #[must_use]
1208    pub fn tabulated(&self) -> &TabulatedResolution {
1209        &self.tabulated
1210    }
1211
1212    /// The IC parameters.
1213    #[must_use]
1214    pub fn params(&self) -> &IkedaCarpenterParams {
1215        &self.params
1216    }
1217
1218    /// Flight-path length (m).
1219    #[must_use]
1220    pub fn flight_path_m(&self) -> f64 {
1221        self.flight_path_m
1222    }
1223
1224    /// The same pulse read against a different flight path.
1225    ///
1226    /// The synthesized kernels are emission-time distributions built from
1227    /// α(E), β(E) and R(E) — no flight path appears in the synthesis. The
1228    /// flight path enters only the TOF↔energy map and the nominal arrival
1229    /// time, so rebinding it is exact and does not resynthesize the table.
1230    ///
1231    /// # Errors
1232    /// Returns [`ResolutionParseError::InvalidFormat`] if `flight_path_m` is
1233    /// not positive and finite.
1234    pub fn with_flight_path(&self, flight_path_m: f64) -> Result<Self, ResolutionParseError> {
1235        Ok(Self {
1236            params: self.params.clone(),
1237            flight_path_m,
1238            ref_energies: Arc::clone(&self.ref_energies),
1239            n_tau: self.n_tau,
1240            tabulated: self.tabulated.with_flight_path(flight_path_m)?,
1241        })
1242    }
1243
1244    /// Reference energies (eV, ascending) the table was synthesized on.
1245    #[must_use]
1246    pub fn ref_energies(&self) -> &[f64] {
1247        &self.ref_energies
1248    }
1249
1250    /// The pulse as the counts path reads it, over the energies the table was
1251    /// synthesized on.
1252    #[must_use]
1253    pub fn detector_pulse(&self) -> DetectorPulse {
1254        DetectorPulse {
1255            params: self.params.clone(),
1256            energy_span_ev: (
1257                self.ref_energies[0],
1258                self.ref_energies[self.ref_energies.len() - 1],
1259            ),
1260            n_tau: self.n_tau,
1261        }
1262    }
1263
1264    /// Evaluate the (burst/channel-folded) IC kernel at one energy.
1265    ///
1266    /// Returns ascending TOF-offsets (µs, 0 = pulse start) and peak-normalized
1267    /// weights (max = 1), matching the [`TabulatedResolution`] storage
1268    /// convention.
1269    ///
1270    /// # Errors
1271    /// Returns [`ResolutionParseError::InvalidFormat`] when the requested
1272    /// energy or an energy-dependent rate/fraction is non-physical, or when
1273    /// the τ-grid cannot resolve the prompt core and requested folds within
1274    /// `MAX_TAU_SAMPLES` at this energy. Construction validates every
1275    /// *reference* energy, but a probe outside `[e_min, e_max]` can still leave
1276    /// the physical or resolvable region.
1277    pub fn kernel_at(&self, energy_ev: f64) -> Result<(Vec<f64>, Vec<f64>), ResolutionParseError> {
1278        self.params.validate_probe_energy(energy_ev)?;
1279        synth_kernel(&self.params, self.n_tau, energy_ev)
1280    }
1281
1282    /// Evaluate the physical source pulse at one true neutron energy.
1283    ///
1284    /// Returns sampled moderator-delay coordinates in µs and peak-normalized
1285    /// densities. Unlike [`Self::kernel_at`], this method does not move the
1286    /// pulse mode to zero. With no symmetric proton/channel fold, the delay is
1287    /// causal and starts at zero. A symmetric fold may extend the sampled
1288    /// support below zero relative to its stated time origin.
1289    ///
1290    /// # Errors
1291    /// Returns [`ResolutionParseError::InvalidFormat`] if `energy_ev` is not a
1292    /// positive finite true energy, if an energy law is unphysical at that
1293    /// energy, or if the requested pulse cannot be resolved by the configured
1294    /// sampling limits.
1295    pub fn source_pulse_at(
1296        &self,
1297        energy_ev: f64,
1298    ) -> Result<(Vec<f64>, Vec<f64>), ResolutionParseError> {
1299        self.params.validate_probe_energy(energy_ev)?;
1300        synth_source_pulse(&self.params, self.n_tau, energy_ev)
1301    }
1302
1303    /// Probability that a neutron of known true energy is recorded in each
1304    /// supplied detector-time bin.
1305    ///
1306    /// `detector_time_edges_us` are the actual measured bin edges. The nominal
1307    /// arrival time is
1308    /// `timing_offset_us + TOF_FACTOR * flight_path_m / sqrt(true_energy_ev)`.
1309    /// `timing_offset_us` represents the shared clock/detector offset; it does
1310    /// not absorb or remove the moderator pulse's physical mode.
1311    ///
1312    /// The returned vector has one entry per adjacent edge pair and is not
1313    /// renormalized to the supplied window: bins that do not cover the full
1314    /// pulse correctly sum to less than one.
1315    ///
1316    /// Each probability is a difference of the analytical IC CDF, folded with
1317    /// the proton-pulse triangle when `channel_fwhm_us` is set.
1318    ///
1319    /// # Errors
1320    /// Returns [`ResolutionParseError::InvalidFormat`] unless the true energy
1321    /// is physical, the timing offset is finite, at least two finite bin
1322    /// edges are supplied in strictly increasing order, and the pulse has no
1323    /// Gaussian burst.
1324    pub fn detector_bin_probabilities(
1325        &self,
1326        true_energy_ev: f64,
1327        detector_time_edges_us: &[f64],
1328        timing_offset_us: f64,
1329    ) -> Result<Vec<f64>, ResolutionParseError> {
1330        if !timing_offset_us.is_finite() {
1331            return Err(ResolutionParseError::InvalidFormat(format!(
1332                "timing_offset_us must be finite, got {timing_offset_us}"
1333            )));
1334        }
1335        self.params.bin_probabilities_at(
1336            true_energy_ev,
1337            timing_offset_us + TOF_FACTOR * self.flight_path_m / true_energy_ev.sqrt(),
1338            detector_time_edges_us,
1339        )
1340    }
1341}
1342
1343/// An Ikeda–Carpenter pulse in detector time: its laws, the energies they hold
1344/// over, and the number of samples across its prompt core used to find its
1345/// rise.
1346#[derive(Debug, Clone)]
1347pub struct DetectorPulse {
1348    params: IkedaCarpenterParams,
1349    energy_span_ev: (f64, f64),
1350    n_tau: usize,
1351}
1352
1353impl DetectorPulse {
1354    /// The pulse with laws `params` over `energy_span_ev`, its rise found on
1355    /// `n_tau` samples across the prompt core.
1356    ///
1357    /// # Errors
1358    /// [`ResolutionParseError::InvalidFormat`] unless `0 < low < high` are
1359    /// finite, `n_tau` is at least 8, the triangle's FWHM is finite and 0 or
1360    /// more, the pulse has no Gaussian burst, a detector tail has its
1361    /// fraction within 0–1 and its decay from 1e-9 to 1e9 µs, and the
1362    /// laws are valid at both ends of the span: `α` and `β` finite and at
1363    /// least 1e-9 µs⁻¹, `R` within 0–1.
1364    pub fn new(
1365        params: IkedaCarpenterParams,
1366        energy_span_ev: (f64, f64),
1367        n_tau: usize,
1368    ) -> Result<Self, ResolutionParseError> {
1369        params.triangle_fwhm_us()?;
1370        params.tail_rate()?;
1371        let (low, high) = energy_span_ev;
1372        if !(low.is_finite() && high.is_finite() && 0.0 < low && low < high) {
1373            return Err(ResolutionParseError::InvalidFormat(format!(
1374                "the energy span must satisfy 0 < low < high, got {energy_span_ev:?}"
1375            )));
1376        }
1377        params.validate_probe_energy(low)?;
1378        params.validate_probe_energy(high)?;
1379        if n_tau < MIN_N_TAU {
1380            return Err(ResolutionParseError::InvalidFormat(format!(
1381                "n_tau must be >= {MIN_N_TAU}, got {n_tau}"
1382            )));
1383        }
1384        Ok(Self {
1385            params,
1386            energy_span_ev,
1387            n_tau,
1388        })
1389    }
1390
1391    /// The pulse's laws.
1392    #[must_use]
1393    pub fn params(&self) -> &IkedaCarpenterParams {
1394        &self.params
1395    }
1396
1397    /// `(low, high)`, the energies in eV the laws hold over.
1398    #[must_use]
1399    pub fn energy_span_ev(&self) -> (f64, f64) {
1400        self.energy_span_ev
1401    }
1402
1403    /// The samples across the prompt core used to find the pulse's rise.
1404    #[must_use]
1405    pub fn n_tau(&self) -> usize {
1406        self.n_tau
1407    }
1408
1409    /// The time in µs from the first sample of the pulse at `energy_ev` to
1410    /// its peak.
1411    ///
1412    /// # Errors
1413    /// [`ResolutionParseError::InvalidFormat`] when `energy_ev` is not
1414    /// positive and finite, a law is singular or out of range there, or the
1415    /// pulse has a Gaussian burst.
1416    pub fn rise_us(&self, energy_ev: f64) -> Result<f64, ResolutionParseError> {
1417        self.params.validate_probe_energy(energy_ev)?;
1418        self.params.triangle_fwhm_us()?;
1419        sampled_rise_us(&self.params, self.n_tau, energy_ev)
1420    }
1421}
1422
1423/// The chance of a later arrival below which an Ikeda–Carpenter pulse's
1424/// tail is treated as ended.
1425pub const NEGLIGIBLE_ARRIVAL_PROBABILITY: f64 = 1e-7;
1426
1427fn tail_delay(pulse: &Recorded) -> f64 {
1428    let later = |tau: f64| 1.0 - pulse.cdf(tau);
1429    let late = pulse.delayed().map_or(f64::INFINITY, |(_, rate)| rate);
1430    let mut high = 1.0 / pulse.alpha.min(pulse.beta).min(late).max(MIN_RATE);
1431    while later(high) > NEGLIGIBLE_ARRIVAL_PROBABILITY {
1432        high *= 2.0;
1433    }
1434    let mut low = 0.0;
1435    while high - low > f64::EPSILON * high {
1436        let middle = 0.5 * (low + high);
1437        if later(middle) > NEGLIGIBLE_ARRIVAL_PROBABILITY {
1438            low = middle;
1439        } else {
1440            high = middle;
1441        }
1442    }
1443    high
1444}
1445
1446fn requested_step(params: &IkedaCarpenterParams, n_tau: usize, alpha: f64) -> (f64, f64, String) {
1447    let fast_reach = FAST_REACH_E_FOLDS / alpha;
1448    let mut dtau_req = fast_reach / (n_tau as f64 - 1.0);
1449    let mut floor = fast_reach / (MIN_N_TAU as f64 - 1.0);
1450    let mut fold_desc = String::new();
1451    if let Some(fwhm) = params.channel_fwhm_us
1452        && fwhm > 0.0
1453    {
1454        let tri_floor = fwhm / TRI_MIN_SAMPLES_PER_SIDE;
1455        dtau_req = dtau_req.min(tri_floor);
1456        floor = floor.min(tri_floor);
1457        fold_desc.push_str(&format!(", channel triangle FWHM = {fwhm} µs"));
1458    }
1459    if let Some(sigma) = params.burst_sigma_us
1460        && sigma > 0.0
1461    {
1462        dtau_req = dtau_req.min(sigma);
1463        floor = floor.min(sigma);
1464        fold_desc.push_str(&format!(", burst σ = {sigma} µs"));
1465    }
1466
1467    (dtau_req, floor, fold_desc)
1468}
1469
1470/// τ-grid geometry for one kernel: `(dtau, tau_max, margin)`, or a
1471/// descriptive error when no exact sampled representation fits the cap.
1472///
1473/// The step is anchored to the PROMPT core — `n_tau` samples across the fast
1474/// Gamma(3) pulse (`fast_reach / (n_tau − 1)`) — and REFINED to resolve any
1475/// requested instrument fold (triangle: ≥ [`TRI_MIN_SAMPLES_PER_SIDE`]
1476/// samples per side, i.e. `dtau ≤ FWHM/TRI_MIN_SAMPLES_PER_SIDE`; Gaussian burst: `dtau ≤ σ`, i.e.
1477/// ≥ [`GAUSS_REACH_SIGMAS`] samples per ±`GAUSS_REACH_SIGMAS`·σ side). A longer storage tail
1478/// (β ≪ α, R > 0) extends the SAMPLE COUNT (`j_hi ∝ tau_max/dtau`) instead
1479/// of the step; [`MAX_TAU_SAMPLES`] bounds that count by widening the step —
1480/// but never past the resolution FLOOR (`fast_reach / (MIN_N_TAU − 1)` for
1481/// the prompt core, the fold minima above for folds). A combination whose
1482/// floor cannot be met within the cap has no faithful sampled representation
1483/// here, so it is rejected loudly: a capped step above the fold width would
1484/// degenerate the sampled triangle to an exact delta `[0,1,0]` (the fold
1485/// silently vanishes), and a capped step above the prompt scale steps OVER
1486/// the prompt pulse entirely (probe: α = 250, β = 0.02, R = 0.1 loses the
1487/// prompt's 0.9 weight share).
1488///
1489/// Equals `max(fast_reach/(n_tau−1), tau_max/(MAX_TAU_SAMPLES−1))` whenever
1490/// no fold is finer than the prompt design step and the capped step stays at
1491/// or below the floor.
1492fn tau_geometry(
1493    params: &IkedaCarpenterParams,
1494    n_tau: usize,
1495    alpha: f64,
1496    beta: f64,
1497    r: f64,
1498) -> Result<(f64, f64, f64), String> {
1499    let tau_max = tau_reach(alpha, beta, r);
1500    let (dtau_req, floor, fold_desc) = requested_step(params, n_tau, alpha);
1501
1502    let capped_step = tau_max / (MAX_TAU_SAMPLES as f64 - 1.0);
1503    if capped_step > floor {
1504        return Err(format!(
1505            "the {MAX_TAU_SAMPLES}-sample τ-grid cap cannot resolve the requested physics: \
1506             the pulse spans τ_max = {tau_max:.3} µs (α = {alpha:.4} µs⁻¹, β = {beta:.4} µs⁻¹, \
1507             R = {r:.3}{fold_desc}), forcing a τ-step of {capped_step:.4} µs — above the \
1508             finest-feature resolution floor of {floor:.4} µs. Increase β (shorter storage \
1509             tail), set R = 0 (drop the storage term), or widen/disable the burst/channel fold"
1510        ));
1511    }
1512    Ok((dtau_req.max(capped_step), tau_max, margin_of(params)))
1513}
1514
1515fn tau_reach(alpha: f64, beta: f64, r: f64) -> f64 {
1516    let fast_reach = FAST_REACH_E_FOLDS / alpha;
1517    if r > R_NEGLIGIBLE {
1518        fast_reach.max(SLOW_REACH_E_FOLDS / beta)
1519    } else {
1520        fast_reach
1521    }
1522}
1523
1524/// Symmetric τ-grid margin for the burst/channel folds:
1525/// ±([`GAUSS_REACH_SIGMAS`]·σ + FWHM), the folds' full reach. These samples
1526/// come ON TOP of [`MAX_TAU_SAMPLES`] (the cap governs the pulse body only).
1527fn margin_of(params: &IkedaCarpenterParams) -> f64 {
1528    params
1529        .burst_sigma_us
1530        .map_or(0.0, |s| GAUSS_REACH_SIGMAS * s)
1531        + params.channel_fwhm_us.unwrap_or(0.0)
1532}
1533
1534/// Synthesize one `(offsets, weights)` kernel for `energy_ev` from the IC
1535/// parameters: sample `I(τ)`, fold in burst + channel, trim negligible tails,
1536/// peak-normalize.
1537///
1538/// Offsets are on the emission clock: `0` is when the pulse begins, which is
1539/// where the Ikeda–Carpenter function puts it. A loaded SAMMY UDR file is
1540/// anchored differently and never passes through here.
1541///
1542/// # Errors
1543/// [`ResolutionParseError::InvalidFormat`] when [`tau_geometry`] cannot
1544/// resolve the prompt core and requested folds within [`MAX_TAU_SAMPLES`].
1545fn synth_kernel(
1546    params: &IkedaCarpenterParams,
1547    n_tau: usize,
1548    energy_ev: f64,
1549) -> Result<(Vec<f64>, Vec<f64>), ResolutionParseError> {
1550    synth_source_pulse(params, n_tau, energy_ev)
1551}
1552
1553/// Synthesize one physical-time source pulse without moving its mode.
1554fn synth_source_pulse_density(
1555    params: &IkedaCarpenterParams,
1556    n_tau: usize,
1557    energy_ev: f64,
1558) -> Result<(Vec<f64>, Vec<f64>), ResolutionParseError> {
1559    let alpha = params.alpha.eval(energy_ev).max(MIN_RATE);
1560    let beta = params.beta.eval(energy_ev).max(MIN_RATE);
1561    let r = params.r.eval(energy_ev).clamp(0.0, 1.0);
1562
1563    let geometry = tau_geometry(params, n_tau, alpha, beta, r).map_err(|msg| {
1564        ResolutionParseError::InvalidFormat(format!(
1565            "Ikeda–Carpenter kernel at E = {energy_ev} eV: {msg}"
1566        ))
1567    })?;
1568    sampled_density(params, (alpha, beta, r), geometry, energy_ev)
1569}
1570
1571fn sampled_rise_us(
1572    params: &IkedaCarpenterParams,
1573    n_tau: usize,
1574    energy_ev: f64,
1575) -> Result<f64, ResolutionParseError> {
1576    let alpha = params.alpha.eval(energy_ev).max(MIN_RATE);
1577    let beta = params.beta.eval(energy_ev).max(MIN_RATE);
1578    let r = params.r.eval(energy_ev).clamp(0.0, 1.0);
1579    let fast_reach = FAST_REACH_E_FOLDS / alpha;
1580    let margin = margin_of(params);
1581    let (dtau_req, _, _) = requested_step(params, n_tau, alpha);
1582    let dtau = dtau_req.max((fast_reach + 2.0 * margin) / (MAX_TAU_SAMPLES as f64 - 1.0));
1583    let (times, densities) = sampled_density(
1584        params,
1585        (alpha, beta, r),
1586        (dtau, fast_reach, margin),
1587        energy_ev,
1588    )?;
1589    Ok(times[argmax(&densities)] - times[0])
1590}
1591
1592fn sampled_density(
1593    params: &IkedaCarpenterParams,
1594    (alpha, beta, r): (f64, f64, f64),
1595    (dtau, tau_max, margin): (f64, f64, f64),
1596    energy_ev: f64,
1597) -> Result<(Vec<f64>, Vec<f64>), ResolutionParseError> {
1598    // Extend the grid to slightly negative τ so a symmetric burst/channel can
1599    // spread the leading edge correctly (the moderator pulse itself is 0 there).
1600    let j_lo: isize = -((margin / dtau).ceil() as isize);
1601    let j_hi: isize = ((tau_max + margin) / dtau).ceil() as isize;
1602
1603    let taus: Vec<f64> = (j_lo..=j_hi).map(|j| j as f64 * dtau).collect();
1604    let mut weights: Vec<f64> = taus.iter().map(|&t| ic_pulse(alpha, beta, r, t)).collect();
1605
1606    let sampled_area = dtau
1607        * (0.5 * weights[0]
1608            + weights[1..weights.len() - 1].iter().sum::<f64>()
1609            + 0.5 * weights[weights.len() - 1]);
1610    if !sampled_area.is_finite() || sampled_area <= 0.0 {
1611        return Err(ResolutionParseError::InvalidFormat(format!(
1612            "Ikeda–Carpenter pulse at E = {energy_ev} eV has zero sampled area"
1613        )));
1614    }
1615
1616    if let Some(sigma) = params.burst_sigma_us
1617        && sigma > 0.0
1618    {
1619        weights = convolve_same(&weights, &gaussian_kernel(dtau, sigma));
1620    }
1621    if let Some(fwhm) = params.channel_fwhm_us
1622        && fwhm > 0.0
1623    {
1624        weights = convolve_same(&weights, &triangle_kernel(dtau, fwhm));
1625    }
1626
1627    let peak_idx = argmax(&weights);
1628    let peak_val = weights[peak_idx].max(f64::MIN_POSITIVE);
1629
1630    // Trim tails below TRIM_REL of peak, keeping one guard sample each side so
1631    // the convolution's neighbor-difference trapezoid widths stay defined.
1632    let thresh = TRIM_REL * peak_val;
1633    let lo = weights
1634        .iter()
1635        .position(|&w| w > thresh)
1636        .map_or(0, |i| i.saturating_sub(1));
1637    let hi = weights
1638        .iter()
1639        .rposition(|&w| w > thresh)
1640        .map_or(weights.len() - 1, |i| (i + 1).min(weights.len() - 1));
1641
1642    let offsets: Vec<f64> = (lo..=hi).map(|j| taus[j]).collect();
1643    let densities: Vec<f64> = (lo..=hi).map(|j| weights[j]).collect();
1644    Ok((offsets, densities))
1645}
1646
1647/// Synthesize the public peak-normalized source-pulse representation.
1648fn synth_source_pulse(
1649    params: &IkedaCarpenterParams,
1650    n_tau: usize,
1651    energy_ev: f64,
1652) -> Result<(Vec<f64>, Vec<f64>), ResolutionParseError> {
1653    let (offsets, densities) = synth_source_pulse_density(params, n_tau, energy_ev)?;
1654    let peak = densities
1655        .iter()
1656        .copied()
1657        .fold(0.0_f64, f64::max)
1658        .max(f64::MIN_POSITIVE);
1659    let weights = densities.into_iter().map(|value| value / peak).collect();
1660    Ok((offsets, weights))
1661}
1662
1663/// Index of the maximum element (first on ties). Slice is non-empty by
1664/// construction in [`synth_kernel`].
1665fn argmax(xs: &[f64]) -> usize {
1666    let mut best = 0;
1667    let mut best_v = xs[0];
1668    for (i, &x) in xs.iter().enumerate().skip(1) {
1669        if x > best_v {
1670            best_v = x;
1671            best = i;
1672        }
1673    }
1674    best
1675}
1676
1677/// Symmetric, unit-sum Gaussian kernel sampled on a `dtau`-spaced grid out to
1678/// ±[`GAUSS_REACH_SIGMAS`]·σ.
1679fn gaussian_kernel(dtau: f64, sigma: f64) -> Vec<f64> {
1680    let half = ((GAUSS_REACH_SIGMAS * sigma / dtau).ceil() as isize).max(1);
1681    let mut k: Vec<f64> = (-half..=half)
1682        .map(|j| {
1683            let t = j as f64 * dtau / sigma;
1684            (-0.5 * t * t).exp()
1685        })
1686        .collect();
1687    normalize_sum(&mut k);
1688    k
1689}
1690
1691/// Symmetric, unit-sum triangle kernel of FWHM `fwhm` (half-base = FWHM;
1692/// variance FWHM²/6) sampled on a `dtau`-spaced grid.
1693fn triangle_kernel(dtau: f64, fwhm: f64) -> Vec<f64> {
1694    let a = fwhm; // half-base equals FWHM for a symmetric triangle
1695    let half = ((a / dtau).ceil() as isize).max(1);
1696    let mut k: Vec<f64> = (-half..=half)
1697        .map(|j| (1.0 - (j as f64 * dtau).abs() / a).max(0.0))
1698        .collect();
1699    normalize_sum(&mut k);
1700    k
1701}
1702
1703fn normalize_sum(k: &mut [f64]) {
1704    let s: f64 = k.iter().sum();
1705    if s > 0.0 {
1706        for v in k.iter_mut() {
1707            *v /= s;
1708        }
1709    }
1710}
1711
1712/// Discrete convolution with a centered symmetric `kernel` (odd length),
1713/// returning an output the same length as `input` (zero-padded edges).
1714fn convolve_same(input: &[f64], kernel: &[f64]) -> Vec<f64> {
1715    let n = input.len();
1716    let kh = (kernel.len() / 2) as isize;
1717    let mut out = vec![0.0f64; n];
1718    for (i, o) in out.iter_mut().enumerate() {
1719        let mut acc = 0.0;
1720        for (kk, &kv) in kernel.iter().enumerate() {
1721            let src = i as isize + (kk as isize - kh);
1722            if src >= 0 && (src as usize) < n {
1723                acc += input[src as usize] * kv;
1724            }
1725        }
1726        *o = acc;
1727    }
1728    out
1729}
1730
1731#[cfg(test)]
1732mod tests {
1733    use super::*;
1734    use crate::resolution::{
1735        ResolutionFunction, apply_resolution, apply_resolution_with_plan, build_resolution_plan,
1736    };
1737    use std::sync::Arc;
1738
1739    /// Trapezoidal integral of `I(τ)` over a fine grid out to many decay times.
1740    fn pulse_area(alpha: f64, beta: f64, r: f64) -> f64 {
1741        let tau_max = (18.0 / alpha).max(if r > 0.0 { 18.0 / beta } else { 0.0 });
1742        let n = 200_000;
1743        let dt = tau_max / n as f64;
1744        let mut area = 0.0;
1745        for i in 0..n {
1746            let t0 = i as f64 * dt;
1747            let t1 = (i + 1) as f64 * dt;
1748            area += 0.5 * (ic_pulse(alpha, beta, r, t0) + ic_pulse(alpha, beta, r, t1)) * dt;
1749        }
1750        area
1751    }
1752
1753    fn pulse_mean(alpha: f64, beta: f64, r: f64) -> f64 {
1754        let tau_max = (24.0 / alpha).max(if r > 0.0 { 24.0 / beta } else { 0.0 });
1755        let n = 400_000;
1756        let dt = tau_max / n as f64;
1757        let mut m = 0.0;
1758        for i in 0..n {
1759            let t = (i as f64 + 0.5) * dt;
1760            m += t * ic_pulse(alpha, beta, r, t) * dt;
1761        }
1762        m
1763    }
1764
1765    #[test]
1766    fn pulse_is_unit_area() {
1767        for &(a, b, r) in &[
1768            (0.5, 0.05, 0.0),
1769            (1.0, 0.1, 0.3),
1770            (2.0, 0.2, 0.6),
1771            (0.8, 0.5, 0.9),
1772        ] {
1773            let area = pulse_area(a, b, r);
1774            assert!(
1775                (area - 1.0).abs() < 1e-3,
1776                "area for (α={a},β={b},R={r}) = {area}, expected 1"
1777            );
1778        }
1779    }
1780
1781    #[test]
1782    fn pulse_mean_matches_formula() {
1783        // ⟨τ⟩ = 3/α + R/β
1784        for &(a, b, r) in &[(1.0, 0.1, 0.0), (1.0, 0.1, 0.4), (2.0, 0.25, 0.7)] {
1785            let want = 3.0 / a + r / b;
1786            let got = pulse_mean(a, b, r);
1787            assert!(
1788                (got - want).abs() / want < 2e-3,
1789                "mean (α={a},β={b},R={r}) = {got}, expected {want}"
1790            );
1791        }
1792    }
1793
1794    #[test]
1795    fn pulse_mode_is_two_over_alpha_for_pure_fast() {
1796        // r=0 ⇒ Gamma(3,α): mode at τ=2/α.
1797        let alpha = 1.3;
1798        let mode = 2.0 / alpha;
1799        let here = ic_pulse(alpha, 0.1, 0.0, mode);
1800        for d in [-0.3, -0.1, 0.1, 0.3] {
1801            assert!(ic_pulse(alpha, 0.1, 0.0, mode + d) <= here + 1e-12);
1802        }
1803    }
1804
1805    #[test]
1806    fn pulse_alpha_equals_beta_is_finite_gamma4() {
1807        // α=β limit: slow term → α⁴τ³/6·e^{−ατ} (Gamma(4)). r=1 isolates it.
1808        let a = 1.0;
1809        let tau = 2.5;
1810        let got = ic_pulse(a, a, 1.0, tau);
1811        let want = a.powi(4) * tau.powi(3) / 6.0 * (-a * tau).exp();
1812        assert!(got.is_finite());
1813        assert!(
1814            (got - want).abs() < 1e-9,
1815            "α=β pulse {got} != Gamma(4) {want}"
1816        );
1817        // Near-degenerate (β just below α) must also be finite and close.
1818        let near = ic_pulse(a, a - 1e-9, 1.0, tau);
1819        assert!(near.is_finite() && (near - want).abs() < 1e-6);
1820    }
1821
1822    #[test]
1823    fn pulse_is_finite_for_beta_much_greater_than_alpha() {
1824        // old e^{−βτ}·h(u) form factored an e^{|u|} that overflowed → 0·∞ = NaN).
1825        for &tau in &[0.0, 1.0, 50.0, 400.0, 1000.0] {
1826            let v = ic_pulse(0.05, 4.0, 0.5, tau);
1827            assert!(
1828                v.is_finite() && v >= 0.0,
1829                "ic_pulse(0.05,4,0.5,{tau}) = {v}"
1830            );
1831        }
1832        // The synthesized kernel must contain no non-finite entries.
1833        let p = IkedaCarpenterParams {
1834            alpha: EnergyLaw::Const(0.05),
1835            beta: EnergyLaw::Const(4.0),
1836            r: EnergyLaw::Const(0.5),
1837            burst_sigma_us: None,
1838            channel_fwhm_us: None,
1839            detector_tail: None,
1840        };
1841        let (offs, wts) = synth_kernel(&p, 600, 1.0).unwrap();
1842        assert!(offs.iter().chain(wts.iter()).all(|v| v.is_finite()));
1843    }
1844
1845    #[test]
1846    fn pulse_is_nonnegative_and_zero_before_t0() {
1847        assert_eq!(ic_pulse(1.0, 0.1, 0.5, -0.5), 0.0);
1848        for i in 0..200 {
1849            let t = i as f64 * 0.1;
1850            assert!(ic_pulse(1.0, 0.1, 0.5, t) >= 0.0);
1851        }
1852    }
1853
1854    #[test]
1855    fn energy_law_eval() {
1856        assert_eq!(EnergyLaw::Const(3.0).eval(50.0), 3.0);
1857        let s = EnergyLaw::SqrtE { a0: 0.2, a1: 0.1 };
1858        assert!((s.eval(100.0) - (0.2 * 10.0 + 0.1)).abs() < 1e-12);
1859        // InverseLambda: α grows with E (λ shrinks).
1860        let il = EnergyLaw::InverseLambda { a0: 0.1, a1: 0.5 };
1861        assert!(il.eval(200.0) > il.eval(5.0));
1862        // R = exp(−E_meV/κ) → ~0 at eV-scale energies, ~1 at sub-meV.
1863        let rr = EnergyLaw::ExpMilliEv { kappa: 25.0 };
1864        assert!(rr.eval(10.0) < 1e-6); // 10 eV
1865        assert!(rr.eval(0.001) > 0.9); // 1 meV
1866    }
1867
1868    /// The synthesized table and the model that produced it place the pulse at
1869    /// the same instant.
1870    ///
1871    /// Both are evaluated on the detector clock for the same `timing_offset_us`,
1872    /// so their mean arrival and their shape must agree.
1873    #[test]
1874    fn synthesized_table_agrees_with_the_model_it_came_from() {
1875        let model = IkedaCarpenter::new(
1876            IkedaCarpenterParams {
1877                alpha: EnergyLaw::SqrtE { a0: 0.35, a1: 0.05 },
1878                beta: EnergyLaw::Const(0.25),
1879                r: EnergyLaw::Const(0.15),
1880                burst_sigma_us: None,
1881                channel_fwhm_us: None,
1882                detector_tail: None,
1883            },
1884            25.0,
1885            &SynthesisGrid {
1886                e_min_ev: 1.0,
1887                e_max_ev: 100.0,
1888                n_energies: 16,
1889                n_tau: 512,
1890            },
1891        )
1892        .expect("valid IC");
1893        let table = model.tabulated();
1894
1895        // Between reference energies, so the width blend is exercised: it
1896        // scales offsets about 0, which is the emission instant.
1897        for energy in [3.7_f64, 17.3, 55.0] {
1898            let tof = TOF_FACTOR * 25.0 / energy.sqrt();
1899            let edges: Vec<f64> = (0..=3000).map(|i| tof - 5.0 + i as f64 * 0.01).collect();
1900            let mean = |p: &[f64]| -> f64 {
1901                let mass: f64 = p.iter().sum();
1902                p.iter()
1903                    .enumerate()
1904                    .map(|(i, w)| w * 0.5 * (edges[i] + edges[i + 1]))
1905                    .sum::<f64>()
1906                    / mass
1907            };
1908            let from_model = model
1909                .detector_bin_probabilities(energy, &edges, 0.0)
1910                .expect("model evaluates");
1911            let from_table = table
1912                .detector_bin_probabilities(energy, &edges, 0.0)
1913                .expect("table evaluates");
1914            let peak = from_model.iter().copied().fold(0.0_f64, f64::max);
1915            let shape = from_model
1916                .iter()
1917                .zip(&from_table)
1918                .map(|(a, b)| (a - b).abs())
1919                .fold(0.0_f64, f64::max);
1920            assert!(
1921                shape < 0.01 * peak,
1922                "E={energy}: table differs from the analytic pulse by {:.2}% of peak",
1923                100.0 * shape / peak
1924            );
1925            let gap = mean(&from_model) - mean(&from_table);
1926            assert!(
1927                gap.abs() < 0.01,
1928                "E={energy}: model puts the pulse at {:.4} µs, its own table at \
1929                 {:.4} µs ({gap:+.4} µs apart)",
1930                mean(&from_model),
1931                mean(&from_table)
1932            );
1933        }
1934    }
1935
1936    #[test]
1937    fn kernel_tail_points_to_positive_offset() {
1938        // Asymmetry: longer/heavier tail toward +offset (later TOF, lower E).
1939        let p = IkedaCarpenterParams::constant(1.0, 0.1, 0.2);
1940        let (offsets, weights) = synth_kernel(&p, 600, 10.0).unwrap();
1941        let max_pos = offsets.iter().cloned().fold(f64::MIN, f64::max);
1942        let min_neg = offsets.iter().cloned().fold(f64::MAX, f64::min);
1943        assert!(
1944            max_pos > min_neg.abs(),
1945            "expected longer +offset tail: +{max_pos} vs −{}",
1946            min_neg.abs()
1947        );
1948        let pos_w: f64 = offsets
1949            .iter()
1950            .zip(&weights)
1951            .filter(|(o, _)| **o > 0.0)
1952            .map(|(_, w)| *w)
1953            .sum();
1954        let neg_w: f64 = offsets
1955            .iter()
1956            .zip(&weights)
1957            .filter(|(o, _)| **o < 0.0)
1958            .map(|(_, w)| *w)
1959            .sum();
1960        assert!(pos_w > neg_w, "expected more weight at +offset");
1961    }
1962
1963    #[test]
1964    fn higher_energy_gives_narrower_kernel() {
1965        // α(E) ∝ √E ⇒ prompt width 1/α shrinks with E ⇒ smaller TOF support.
1966        let p = IkedaCarpenterParams {
1967            alpha: EnergyLaw::SqrtE { a0: 0.3, a1: 0.0 },
1968            beta: EnergyLaw::Const(0.1),
1969            r: EnergyLaw::Const(0.0),
1970            burst_sigma_us: None,
1971            channel_fwhm_us: None,
1972            detector_tail: None,
1973        };
1974        let support = |e: f64| {
1975            let (o, _) = synth_kernel(&p, 600, e).unwrap();
1976            o.iter().cloned().fold(f64::MIN, f64::max) - o.iter().cloned().fold(f64::MAX, f64::min)
1977        };
1978        assert!(support(100.0) < support(5.0));
1979    }
1980
1981    #[test]
1982    fn synthesize_builds_valid_ascending_table() {
1983        let p = IkedaCarpenterParams::constant(1.0, 0.1, 0.2);
1984        let grid = SynthesisGrid {
1985            e_min_ev: 0.5e-3,
1986            e_max_ev: 1000.0,
1987            n_energies: 32,
1988            n_tau: 400,
1989        };
1990        let ic = IkedaCarpenter::new(p, 25.0, &grid).expect("synthesis");
1991        assert_eq!(ic.ref_energies().len(), 32);
1992        assert_eq!(ic.tabulated().ref_energies().len(), 32);
1993        for w in ic.ref_energies().windows(2) {
1994            assert!(w[1] > w[0]);
1995        }
1996    }
1997
1998    #[test]
1999    fn rejects_bad_config() {
2000        let p = IkedaCarpenterParams::constant(1.0, 0.1, 0.2);
2001        let bad = SynthesisGrid {
2002            e_min_ev: 1.0,
2003            e_max_ev: 0.5,
2004            n_energies: 16,
2005            n_tau: 100,
2006        };
2007        assert!(IkedaCarpenter::new(p.clone(), 25.0, &bad).is_err());
2008        assert!(IkedaCarpenter::new(p, -1.0, &SynthesisGrid::new(1.0, 10.0)).is_err());
2009        // A parameter law that yields α(E) ≤ 0 is rejected, not silently clamped.
2010        let neg_alpha = IkedaCarpenterParams {
2011            alpha: EnergyLaw::Const(-1.0),
2012            ..IkedaCarpenterParams::constant(1.0, 0.1, 0.0)
2013        };
2014        assert!(IkedaCarpenter::new(neg_alpha, 25.0, &SynthesisGrid::new(1.0, 100.0)).is_err());
2015        // Negative / non-finite burst or channel widths are rejected up front
2016        // (not `.abs()`-masked or NaN-swallowed during synthesis).
2017        for bad_width in [-1.0, f64::NAN, f64::INFINITY] {
2018            let neg_burst = IkedaCarpenterParams {
2019                burst_sigma_us: Some(bad_width),
2020                ..IkedaCarpenterParams::constant(1.0, 0.1, 0.0)
2021            };
2022            assert!(
2023                IkedaCarpenter::new(neg_burst, 25.0, &SynthesisGrid::new(1.0, 100.0)).is_err(),
2024                "burst_sigma_us={bad_width} should be rejected"
2025            );
2026            let neg_chan = IkedaCarpenterParams {
2027                channel_fwhm_us: Some(bad_width),
2028                ..IkedaCarpenterParams::constant(1.0, 0.1, 0.0)
2029            };
2030            assert!(
2031                IkedaCarpenter::new(neg_chan, 25.0, &SynthesisGrid::new(1.0, 100.0)).is_err(),
2032                "channel_fwhm_us={bad_width} should be rejected"
2033            );
2034        }
2035    }
2036
2037    #[test]
2038    fn broadens_constant_to_constant() {
2039        // An area-normalized kernel preserves a flat spectrum.
2040        let p = IkedaCarpenterParams::constant(1.0, 0.1, 0.3);
2041        let ic = IkedaCarpenter::new(p, 25.0, &SynthesisGrid::new(1.0, 200.0)).unwrap();
2042        let energies: Vec<f64> = (0..400).map(|i| 1.0 + i as f64 * 0.5).collect();
2043        let spectrum = vec![0.7f64; energies.len()];
2044        let res = ResolutionFunction::IkedaCarpenter(Arc::new(ic));
2045        let out = apply_resolution(&energies, &spectrum, &res).unwrap();
2046        // Interior points (away from grid edges where the kernel is clipped).
2047        for v in &out[40..energies.len() - 40] {
2048            assert!((v - 0.7).abs() < 1e-3, "flat broadening drifted: {v}");
2049        }
2050    }
2051
2052    #[test]
2053    fn ic_centering_shifts_broadened_symmetric_dip_with_alpha() {
2054        // The IC kernel anchors its MODE at offset 0, but the right-skewed pulse's
2055        // intensity centroid lags the mode by ~1/α in TOF (module docstring).
2056        // Broadening a symmetric-in-energy resonance therefore moves the dip
2057        // minimum off the nominal energy by an α-dependent amount. This guards the
2058        // documented bias (the loop-closure calibration tests cannot see it) and
2059        // pins it against a silent "fix" (e.g. re-centering on the centroid).
2060        const E0: f64 = 20.0;
2061        // Absorption-weighted centroid of the broadened dip — a robust position
2062        // estimator (the bare minimum is fragile under the wide low-α kernel).
2063        fn dip_centroid_energy(alpha: f64) -> f64 {
2064            // Constant-α kernel so the shape (hence the mode→mean lag) is uniform.
2065            let p = IkedaCarpenterParams::constant(alpha, 0.1, 0.0);
2066            let ic = IkedaCarpenter::new(p, 25.0, &SynthesisGrid::new(1.0, 200.0)).unwrap();
2067            let res = ResolutionFunction::IkedaCarpenter(Arc::new(ic));
2068            // Fine uniform grid; symmetric (in energy) Gaussian dip centered at E0.
2069            let energies: Vec<f64> = (0..4000).map(|i| 10.0 + i as f64 * 0.005).collect();
2070            let spectrum: Vec<f64> = energies
2071                .iter()
2072                .map(|&e| 1.0 - 0.8 * (-((e - E0) / 0.1).powi(2)).exp())
2073                .collect();
2074            let out = apply_resolution(&energies, &spectrum, &res).unwrap();
2075            let (mut num, mut den) = (0.0, 0.0);
2076            for (&e, &t) in energies.iter().zip(&out) {
2077                if (e - E0).abs() <= 2.0 {
2078                    let a = (1.0 - t).max(0.0); // absorption weight
2079                    num += e * a;
2080                    den += a;
2081                }
2082            }
2083            num / den
2084        }
2085        let signed_small_alpha = dip_centroid_energy(0.8) - E0;
2086        let signed_large_alpha = dip_centroid_energy(2.0) - E0;
2087        // The shift is toward LOWER apparent energy: the delayed-emission
2088        // tail gathers theory from earlier TOF (higher E), so the theory
2089        // dip at E0 surfaces at measured energies below E0. A positive
2090        // shift here means the kernel was applied time-mirrored.
2091        assert!(
2092            signed_small_alpha < 0.0 && signed_large_alpha < 0.0,
2093            "centering shift must be toward lower energy: \
2094             α=0.8 {signed_small_alpha:+e}, α=2.0 {signed_large_alpha:+e}"
2095        );
2096        let shift_small_alpha = signed_small_alpha.abs();
2097        let shift_large_alpha = signed_large_alpha.abs();
2098        // The bias is real (resolvable at the 1e-3 eV level)…
2099        assert!(
2100            shift_small_alpha > 1e-3,
2101            "centering shift vanished: {shift_small_alpha}"
2102        );
2103        // …and shrinks with increasing α (the ~1/α scaling of the mode→mean lag).
2104        assert!(
2105            shift_small_alpha > 1.3 * shift_large_alpha,
2106            "shift should scale ~1/α: α=0.8 {shift_small_alpha} vs α=2.0 {shift_large_alpha}"
2107        );
2108    }
2109
2110    #[test]
2111    fn plan_path_matches_direct_path() {
2112        let p = IkedaCarpenterParams::constant(1.2, 0.15, 0.25);
2113        let ic = IkedaCarpenter::new(p, 25.0, &SynthesisGrid::new(1.0, 200.0)).unwrap();
2114        let res = ResolutionFunction::IkedaCarpenter(Arc::new(ic));
2115        let energies: Vec<f64> = (0..300).map(|i| 1.0 + i as f64 * 0.6).collect();
2116        // A localized dip to exercise the convolution non-trivially.
2117        let spectrum: Vec<f64> = energies
2118            .iter()
2119            .map(|&e| if (e - 90.0).abs() < 5.0 { 0.2 } else { 1.0 })
2120            .collect();
2121        let direct = apply_resolution(&energies, &spectrum, &res).unwrap();
2122        let plan = build_resolution_plan(&energies, &res).unwrap();
2123        let planned =
2124            apply_resolution_with_plan(plan.as_ref(), &energies, &spectrum, &res).unwrap();
2125        assert_eq!(direct.len(), planned.len());
2126        for (a, b) in direct.iter().zip(&planned) {
2127            assert!((a - b).abs() < 1e-12, "plan vs direct mismatch: {a} vs {b}");
2128        }
2129    }
2130
2131    #[test]
2132    fn tau_step_anchors_to_prompt_core_not_storage_tail() {
2133        // Regression for the τ-grid fix: with a slow storage tail active
2134        // (β ≪ α, R > 0) the τ-step must stay anchored to the prompt core —
2135        // the old `tau_max/(n_tau−1)` step let the tail dilate dtau and
2136        // degenerate the 0.35 µs channel triangle toward a delta.
2137        let n_tau = 600;
2138        let alpha = 2.0; // fast_reach = 18/α = 9 µs
2139        let prompt_step = (18.0 / alpha) / (n_tau as f64 - 1.0);
2140
2141        let p = IkedaCarpenterParams {
2142            channel_fwhm_us: Some(0.35),
2143            ..IkedaCarpenterParams::constant(alpha, 0.25, 0.5)
2144        };
2145        let (offs, wts) = synth_kernel(&p, n_tau, 10.0).unwrap();
2146        let dtau = offs[1] - offs[0];
2147        assert!(
2148            (dtau - prompt_step).abs() < 1e-12,
2149            "uncapped step {dtau} != prompt-core step {prompt_step}"
2150        );
2151        assert!(
2152            dtau <= prompt_step + 1e-12,
2153            "prompt-core spacing {dtau} > fast_reach/(n_tau−1) = {prompt_step}"
2154        );
2155        // ≥ 3 nonzero triangle samples per side at this step.
2156        let tri = triangle_kernel(dtau, 0.35);
2157        let nonzero_per_side = tri.iter().take(tri.len() / 2).filter(|&&v| v > 0.0).count();
2158        assert!(
2159            nonzero_per_side >= 3,
2160            "triangle degenerated: {nonzero_per_side} nonzero samples per side"
2161        );
2162        // The tail is still reached: for β = 0.25 the slow tail crosses the
2163        // TRIM_REL = 1e-7 trim level near τ ≈ 63 µs (e^{−βτ}·slow-amplitude ÷
2164        // peak = 1e-7 at τ ≈ 62.6 µs), so the trimmed kernel must still span
2165        // well past 40 µs — a step anchored to τ_max instead of the prompt
2166        // core would pass a narrow-span check, but a grid that stops short of
2167        // the storage tail would not survive this one.
2168        let span = offs.last().unwrap() - offs.first().unwrap();
2169        let peak = wts.iter().cloned().fold(f64::MIN, f64::max);
2170        assert!((peak - 1.0).abs() < 1e-12, "weights not peak-normalized");
2171        assert!(span > 3.0 / alpha, "kernel span {span} lost the pulse body");
2172        assert!(
2173            span > 40.0,
2174            "kernel span {span} µs stops short of the β = 0.25 storage tail \
2175             (trim horizon ≈ 63 µs)"
2176        );
2177
2178        // (b) Extreme admitted tail (β = 0.02 ⇒ slow_reach = 800 µs): the
2179        // MAX_TAU_SAMPLES cap bites; the step widens to tau_max/(cap−1) but
2180        // must still resolve the 0.35 µs triangle with ≥ 3 samples per side
2181        // (the old tail-anchored step was 800/599 ≈ 1.34 µs — a delta).
2182        let p_ext = IkedaCarpenterParams {
2183            channel_fwhm_us: Some(0.35),
2184            ..IkedaCarpenterParams::constant(alpha, 0.02, 0.5)
2185        };
2186        let (offs_ext, _) = synth_kernel(&p_ext, n_tau, 10.0).unwrap();
2187        let dtau_ext = offs_ext[1] - offs_ext[0];
2188        let capped_step = 800.0 / (MAX_TAU_SAMPLES as f64 - 1.0);
2189        assert!(
2190            (dtau_ext - capped_step).abs() < 1e-9,
2191            "capped step {dtau_ext} != tau_max/(MAX_TAU_SAMPLES−1) = {capped_step}"
2192        );
2193        assert!(
2194            dtau_ext <= 0.35 / 3.0,
2195            "capped step {dtau_ext} cannot resolve the 0.35 µs triangle"
2196        );
2197        let tri_ext = triangle_kernel(dtau_ext, 0.35);
2198        let nonzero_ext = tri_ext
2199            .iter()
2200            .take(tri_ext.len() / 2)
2201            .filter(|&&v| v > 0.0)
2202            .count();
2203        assert!(
2204            nonzero_ext >= 3,
2205            "triangle degenerated under cap: {nonzero_ext} nonzero samples per side"
2206        );
2207    }
2208
2209    #[test]
2210    fn burst_and_channel_broaden_further() {
2211        // Folding in burst + channel widens the kernel TOF support.
2212        let base = IkedaCarpenterParams::constant(1.0, 0.1, 0.0);
2213        let (o0, _) = synth_kernel(&base, 600, 10.0).unwrap();
2214        let support0 = o0.iter().cloned().fold(f64::MIN, f64::max)
2215            - o0.iter().cloned().fold(f64::MAX, f64::min);
2216        let folded = IkedaCarpenterParams {
2217            burst_sigma_us: Some(0.3),
2218            channel_fwhm_us: Some(0.35),
2219            ..IkedaCarpenterParams::constant(1.0, 0.1, 0.0)
2220        };
2221        let (o1, _) = synth_kernel(&folded, 600, 10.0).unwrap();
2222        let support1 = o1.iter().cloned().fold(f64::MIN, f64::max)
2223            - o1.iter().cloned().fold(f64::MAX, f64::min);
2224        assert!(support1 > support0, "folded {support1} !> bare {support0}");
2225    }
2226
2227    /// Sum-weighted variance of a sampled `(offsets, weights)` kernel on a
2228    /// uniform τ-grid. Normalization-independent (divides by Σw).
2229    fn kernel_variance(offsets: &[f64], weights: &[f64]) -> f64 {
2230        let w_sum: f64 = weights.iter().sum();
2231        let mean: f64 = offsets.iter().zip(weights).map(|(o, w)| o * w).sum::<f64>() / w_sum;
2232        offsets
2233            .iter()
2234            .zip(weights)
2235            .map(|(o, w)| (o - mean).powi(2) * w)
2236            .sum::<f64>()
2237            / w_sum
2238    }
2239
2240    #[test]
2241    fn unresolvable_tau_grid_is_rejected_loudly() {
2242        // Review F1, probe 1: β = 0.005 ⇒ slow reach 16/β = 3200 µs ⇒
2243        // capped step 3200/8191 ≈ 0.39 µs > the 0.35 µs triangle FWHM — the
2244        // sampled triangle would be the exact delta [0, 1, 0] and the
2245        // requested fold would silently vanish from the kernel. Must be a
2246        // loud construction error, not silent physics degradation.
2247        let p1 = IkedaCarpenterParams {
2248            channel_fwhm_us: Some(0.35),
2249            ..IkedaCarpenterParams::constant(2.0, 0.005, 0.5)
2250        };
2251        let err = IkedaCarpenter::new(p1.clone(), 25.0, &SynthesisGrid::new(1.0, 100.0))
2252            .expect_err("a delta-degenerate fold must be rejected");
2253        let msg = format!("{err:?}");
2254        assert!(
2255            msg.contains("cannot resolve") && msg.contains("Increase"),
2256            "error must diagnose the cap and name remedies: {msg}"
2257        );
2258        // The per-energy synthesis (kernel_at path) refuses identically.
2259        assert!(synth_kernel(&p1, 600, 10.0).is_err());
2260
2261        // Probe 2: α = 250 ⇒ the whole prompt pulse spans 18/α ≈ 0.07 µs,
2262        // less than ONE capped step (800/8191 ≈ 0.098 µs): sampling would
2263        // step over the prompt term entirely (0.9 of the pulse weight at
2264        // R = 0.1). Must also be rejected.
2265        let p2 = IkedaCarpenterParams::constant(250.0, 0.02, 0.1);
2266        assert!(
2267            IkedaCarpenter::new(p2, 25.0, &SynthesisGrid::new(1.0, 100.0)).is_err(),
2268            "a capped step wider than the prompt core must be rejected"
2269        );
2270    }
2271
2272    #[test]
2273    fn requested_fold_is_never_a_silent_no_op() {
2274        // Adjacent to probe 1 but resolvable (β = 0.05 ⇒ capped step
2275        // 320/8191 ≈ 0.039 µs ≤ FWHM/3): when synthesis is Ok and a fold is
2276        // requested, the folded kernel must genuinely differ from the
2277        // unfolded one — by approximately the analytic fold variance
2278        // FWHM²/6 — never by ~0 (the silent delta no-op this guards against).
2279        let base = IkedaCarpenterParams::constant(2.0, 0.05, 0.5);
2280        let folded = IkedaCarpenterParams {
2281            channel_fwhm_us: Some(0.35),
2282            ..base.clone()
2283        };
2284        let (o0, w0) = synth_kernel(&base, 600, 10.0).unwrap();
2285        let (o1, w1) = synth_kernel(&folded, 600, 10.0).unwrap();
2286        let dv = kernel_variance(&o1, &w1) - kernel_variance(&o0, &w0);
2287        let expected = 0.35f64.powi(2) / 6.0;
2288        assert!(
2289            dv > 0.5 * expected,
2290            "fold variance increment {dv} µs² vs analytic {expected} µs²: \
2291             the requested fold (nearly) vanished"
2292        );
2293    }
2294
2295    #[test]
2296    fn boundary_step_fwhm_over_3_is_admitted_and_fold_survives() {
2297        // Pin the exactly-admitted boundary `dtau = FWHM/3` (rejection is
2298        // STRICT: `capped_step > floor`). Per the TRI_MIN_SAMPLES_PER_SIDE
2299        // doc, each triangle side then samples
2300        // at {FWHM/3, 2FWHM/3, FWHM} — weights {2/3, 1/3, 0}: exactly 2
2301        // strictly interior nonzero samples, endpoint ON the triangle zero —
2302        // and the fold stays effective (non-delta, second moment ≈ 4FWHM²/27).
2303        //
2304        // Route to the boundary: n_tau = MIN_N_TAU = 8 with α = 1 gives a
2305        // prompt design step 18/7 ≈ 2.57 µs, far coarser than FWHM/3 for a
2306        // 1 µs triangle, so the fold refinement pins dtau to exactly
2307        // `fwhm / TRI_MIN_SAMPLES_PER_SIDE`. R = 0 keeps the cap inert
2308        // (capped step 18/8191 ≪ floor).
2309        let fwhm = 1.0;
2310        let p = IkedaCarpenterParams {
2311            channel_fwhm_us: Some(fwhm),
2312            ..IkedaCarpenterParams::constant(1.0, 0.1, 0.0)
2313        };
2314        let (offs, _) = synth_kernel(&p, MIN_N_TAU, 10.0)
2315            .expect("the dtau = FWHM/3 boundary must be admitted, not rejected");
2316        let dtau = offs[1] - offs[0];
2317        let boundary = fwhm / TRI_MIN_SAMPLES_PER_SIDE;
2318        assert!(
2319            (dtau - boundary).abs() < 1e-12,
2320            "step {dtau} µs is not the FWHM/3 boundary {boundary} µs"
2321        );
2322
2323        // The sampled triangle at the boundary: ≥ 7 samples, exactly 2
2324        // strictly interior nonzero per side, center far below the delta's 1.
2325        let tri = triangle_kernel(dtau, fwhm);
2326        assert!(tri.len() >= 7, "boundary triangle too short: {}", tri.len());
2327        let per_side = tri
2328            .iter()
2329            .take(tri.len() / 2)
2330            .filter(|&&v| v > 1e-9)
2331            .count();
2332        assert_eq!(
2333            per_side, 2,
2334            "boundary triangle must keep 2 strictly interior nonzero samples \
2335             per side: {tri:?}"
2336        );
2337        let center = tri[tri.len() / 2];
2338        assert!(
2339            center < 0.5,
2340            "center weight {center} — boundary triangle degenerated toward a delta"
2341        );
2342
2343        // Fold effectiveness: the discrete triangle's variance is 4·FWHM²/27
2344        // (samples {0, ±FWHM/3, ±2FWHM/3} with weights ∝ {1, 2/3, 1/3}),
2345        // ≈ 89 % of the analytic FWHM²/6 — the fold physics survives the
2346        // 2-interior-sample discretization.
2347        let tri_offs: Vec<f64> = (0..tri.len())
2348            .map(|i| (i as f64 - (tri.len() / 2) as f64) * dtau)
2349            .collect();
2350        let v = kernel_variance(&tri_offs, &tri);
2351        let want = 4.0 * fwhm * fwhm / 27.0;
2352        assert!(
2353            ((v - want) / want).abs() < 0.02,
2354            "boundary triangle variance {v} µs² vs discrete analytic {want} µs²"
2355        );
2356        assert!(
2357            v > 0.5 * (fwhm * fwhm / 6.0),
2358            "boundary fold variance {v} µs² collapsed below half the \
2359             continuous FWHM²/6 — fold (nearly) vanished"
2360        );
2361    }
2362
2363    #[test]
2364    fn fold_variance_matches_analytic_oracle() {
2365        // Independent analytic oracle for the fold convention: a
2366        // convolution adds second central moments, so the sampled kernel's
2367        // variance must grow by EXACTLY the fold kernel's analytic variance —
2368        // FWHM²/6 for the symmetric channel triangle (half-base = FWHM),
2369        // σ² for the Gaussian burst. Unlike the closed-loop calibration
2370        // tests, the expectation here comes from analysis, not from the
2371        // shared synthesis code. Pure-prompt pulse (R = 0) keeps trim /
2372        // edge-truncation error far below the increments.
2373        let base = IkedaCarpenterParams::constant(2.0, 0.1, 0.0);
2374        let (o0, w0) = synth_kernel(&base, 600, 10.0).unwrap();
2375        let v0 = kernel_variance(&o0, &w0);
2376        // Sanity: the bare Gamma(3, α) variance is 3/α².
2377        let v_gamma = 3.0 / (2.0f64 * 2.0);
2378        assert!(
2379            (v0 - v_gamma).abs() < 0.01 * v_gamma,
2380            "bare pulse variance {v0}, Gamma(3) analytic {v_gamma}"
2381        );
2382
2383        let fwhm = 0.35;
2384        let tri = IkedaCarpenterParams {
2385            channel_fwhm_us: Some(fwhm),
2386            ..base.clone()
2387        };
2388        let (o1, w1) = synth_kernel(&tri, 600, 10.0).unwrap();
2389        let dv_tri = kernel_variance(&o1, &w1) - v0;
2390        let want_tri = fwhm * fwhm / 6.0;
2391        assert!(
2392            ((dv_tri - want_tri) / want_tri).abs() < 0.02,
2393            "triangle fold added {dv_tri} µs², analytic FWHM²/6 = {want_tri} µs²"
2394        );
2395
2396        let sigma = 0.3;
2397        let gau = IkedaCarpenterParams {
2398            burst_sigma_us: Some(sigma),
2399            ..base.clone()
2400        };
2401        let (o2, w2) = synth_kernel(&gau, 600, 10.0).unwrap();
2402        let dv_gau = kernel_variance(&o2, &w2) - v0;
2403        let want_gau = sigma * sigma;
2404        assert!(
2405            ((dv_gau - want_gau) / want_gau).abs() < 0.02,
2406            "Gaussian burst added {dv_gau} µs², analytic σ² = {want_gau} µs²"
2407        );
2408    }
2409}