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