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