nereids_physics/resolution.rs
1//! Resolution broadening via convolution with instrument resolution function.
2//!
3//! Convolves theoretical cross-sections (or transmission) with the instrument
4//! resolution function to account for finite energy resolution. The resolution
5//! function is modeled as a Gaussian with energy-dependent width, optionally
6//! combined with an exponential tail, derived from time-of-flight instrument
7//! parameters.
8//!
9//! ## SAMMY Reference
10//! - `rsl/mrsl1.f90` — Main RSL resolution broadening routines (Resbrd)
11//! - `rsl/mrsl4.f90` — Resolution width calculation (Wdsint, Rolowg)
12//! - `rsl/mrsl5.f90` — Exponential tail peak shift (Shftge)
13//! - `fnc/exerfc.f90` — Scaled complementary error function
14//! - `convolution/DopplerAndResolutionBroadener.cpp` — Xcoef quadrature weights
15//! - Manual Section III.C (Resolution Broadening); quadrature Eq. IV B 3.8
16//! (R3-revision numbering — see `compute_xcoef_weights` and the
17//! Gaussian+exponential path in `resolution_broaden_presorted`)
18//!
19//! ## Width convention — read before supplying a number
20//!
21//! Every width here is a **W-parameter**, the width appearing in
22//! `exp(-x²/W²)`, not a standard deviation. The two differ by √2:
23//!
24//! σ = W/√2 FWHM = 2·√(ln 2)·W = 1.6651·W
25//!
26//! This is SAMMY's convention, and it is the same one the Doppler width Δ_D
27//! uses, so the two broadening kernels compose without a conversion. Supplying
28//! a 1σ value where a W is expected yields a kernel √2 too narrow — 29 % — and
29//! a resolution that is too narrow is absorbed into a fitted temperature that
30//! is too high.
31//!
32//! [`ResolutionParams::from_sigma`] and [`ResolutionParams::from_fwhm`] convert
33//! for you. Use them rather than scaling by hand.
34//!
35//! SAMMY's own inputs are in yet other measures — `Deltag` is a FWHM and
36//! `Deltal` is the full width of a rectangular path spread — so reading a
37//! SAMMY `.inp` goes through `nereids_endf::sammy::sammy_to_nereids_resolution`
38//! (`Deltag/(2√ln2)`, `Deltal/√6`), never straight into these fields.
39//!
40//! ## Physics
41//!
42//! For a time-of-flight instrument, the energy resolution is:
43//!
44//! (ΔE/E)² = (2·Δt/t)² + (2·ΔL/L)²
45//!
46//! where t = L/v is the neutron time-of-flight, Δt is the total timing width,
47//! and ΔL is the flight path width — both W-parameters, as above, so ΔE is one
48//! too. The factor 2 is the kinematic derivative dE/E = 2·dt/t; it is not a
49//! width-measure conversion. Since t ∝ 1/√E, the timing contribution gives
50//! ΔE ∝ E^(3/2) while the path contribution gives ΔE ∝ E.
51//!
52//! The broadened cross-section is:
53//!
54//! σ_res(E) = ∫ R(E, E') · σ(E') dE'
55//!
56//! When Deltae = 0, R is a pure Gaussian (Iesopr=1):
57//! R(E, E') = exp(-(E-E')²/Wg²) / (Wg·√π)
58//!
59//! When Deltae > 0, R is the convolution of a Gaussian with an exponential
60//! tail (Iesopr=3):
61//! R(E, E') ∝ exp(2·C·A + C²) · erfc(C + A)
62//!
63//! where C = Wg/(2·We), A = (E - E')/Wg, Wg = Gaussian width, We = exponential
64//! width. This is the analytical result for convolving exp(-x²/Wg²) with
65//! exp(-x/We)·H(x).
66
67use nereids_core::constants::{DIVISION_FLOOR, NEAR_ZERO_FLOOR};
68use std::f64::consts::SQRT_2;
69use std::fmt;
70use std::sync::Arc;
71
72/// FWHM of `exp(-x²/W²)` divided by W: `2·√(ln 2)` = 1.6651.
73///
74/// The conversion between this module's W-parameters and a full width at half
75/// maximum. Paired with σ = W/√2 it fixes all three width measures against each
76/// other; see the module's width-convention section.
77const FWHM_PER_W: f64 = 1.665_109_222_315_395_4;
78
79/// TOF conversion factor: `t (μs) = TOF_FACTOR × L (m) / √(E in eV)`.
80///
81/// Derived from t = L / √(2E/m_n), converting to microseconds:
82/// TOF_FACTOR = 1e6 / √(2 × EV_TO_JOULES / NEUTRON_MASS_KG)
83///
84/// Uses CODATA 2018 values (both exact in the 2019 SI).
85///
86/// `pub` so the analytical [`crate::ikeda_carpenter`] model and the
87/// `nereids-fitting` resolution calibrator both use the *identical* TOF↔energy
88/// constant (the calibrator's position nuisance shifts the grid in TOF) — any
89/// drift here would make the IC-vs-tabulated cross-validation unfair.
90pub const TOF_FACTOR: f64 = 72.298_254_398_292_8;
91
92/// Errors from resolution broadening operations.
93#[derive(Debug, PartialEq)]
94pub enum ResolutionError {
95 /// The energy grid is not sorted in ascending order.
96 UnsortedEnergies,
97 /// The energy grid and data arrays have mismatched lengths.
98 LengthMismatch { energies: usize, data: usize },
99 /// A [`ResolutionPlan`] was passed together with an `energies`
100 /// slice that does not match the grid the plan was built for.
101 ///
102 /// Cheapest-available check hierarchy: length mismatch is caught
103 /// first via [`Self::LengthMismatch`] (`plan.len() ==
104 /// energies.len()` is necessary but not sufficient); a content
105 /// mismatch fires `PlanGridMismatch` with the index of the first
106 /// differing element so callers can diagnose silent-staleness
107 /// bugs at the cache layer.
108 PlanGridMismatch { first_diff_index: usize },
109 /// A [`ResolutionMatrix`] was passed together with an `energies`
110 /// slice that does not match the grid the matrix was compiled for.
111 /// Same semantics as [`Self::PlanGridMismatch`] but for the CSR
112 /// path (see [`apply_r`]).
113 MatrixGridMismatch { first_diff_index: usize },
114 /// [`TabulatedResolution::width_corrected`] was called with invalid
115 /// parameters: `s0` must be finite and `> 0`, `e_ref` finite and `> 0`,
116 /// and `p` finite. A non-positive `s0` would reverse/collapse the
117 /// (ascending) offset ordering the broadening loop assumes.
118 InvalidWidthCorrection { s0: f64, p: f64, e_ref: f64 },
119}
120
121impl fmt::Display for ResolutionError {
122 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123 match self {
124 Self::UnsortedEnergies => write!(
125 f,
126 "energy grid must be sorted in non-descending order for binary search"
127 ),
128 Self::LengthMismatch { energies, data } => write!(
129 f,
130 "energy grid length ({}) must match data length ({})",
131 energies, data
132 ),
133 Self::PlanGridMismatch { first_diff_index } => write!(
134 f,
135 "resolution plan was built for a different energy grid than was \
136 passed to apply_resolution_with_plan (first differing index: {})",
137 first_diff_index,
138 ),
139 Self::MatrixGridMismatch { first_diff_index } => write!(
140 f,
141 "resolution matrix was compiled for a different energy grid than was \
142 passed to apply_resolution_with_matrix (first differing index: {})",
143 first_diff_index,
144 ),
145 Self::InvalidWidthCorrection { s0, p, e_ref } => write!(
146 f,
147 "width_corrected requires finite s0 > 0, finite e_ref > 0, and finite p; \
148 got s0={s0}, p={p}, e_ref={e_ref}"
149 ),
150 }
151 }
152}
153
154impl std::error::Error for ResolutionError {}
155
156/// Errors from `ResolutionParams` construction.
157#[derive(Debug, PartialEq)]
158pub enum ResolutionParamsError {
159 /// Flight path must be positive and finite.
160 InvalidFlightPath(f64),
161 /// Timing uncertainty must be non-negative and finite.
162 InvalidDeltaT(f64),
163 /// Path length uncertainty must be non-negative and finite.
164 InvalidDeltaL(f64),
165 /// Exponential tail parameter must be non-negative and finite.
166 InvalidDeltaE(f64),
167}
168
169impl fmt::Display for ResolutionParamsError {
170 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171 match self {
172 Self::InvalidFlightPath(v) => {
173 write!(f, "flight_path_m must be positive and finite, got {v}")
174 }
175 Self::InvalidDeltaT(v) => {
176 write!(f, "delta_t_us must be non-negative and finite, got {v}")
177 }
178 Self::InvalidDeltaL(v) => {
179 write!(f, "delta_l_m must be non-negative and finite, got {v}")
180 }
181 Self::InvalidDeltaE(v) => {
182 write!(f, "delta_e_us must be non-negative and finite, got {v}")
183 }
184 }
185 }
186}
187
188impl std::error::Error for ResolutionParamsError {}
189
190/// Resolution function parameters for time-of-flight instruments.
191#[derive(Debug, Clone, Copy)]
192pub struct ResolutionParams {
193 /// Flight path length in meters (source to detector).
194 flight_path_m: f64,
195 /// Total timing width in microseconds, as a W-parameter (σ = W/√2).
196 /// Combines moderator pulse width, detector timing, and electronics.
197 ///
198 /// Not a standard deviation — see the module's width-convention section.
199 delta_t_us: f64,
200 /// Flight path width in meters, as a W-parameter (σ = W/√2).
201 delta_l_m: f64,
202 /// Exponential tail parameter (SAMMY Deltae, raw SAMMY units).
203 ///
204 /// When zero, pure Gaussian broadening is used (SAMMY Iesopr=1).
205 /// When positive, the kernel is the convolution of a Gaussian with an
206 /// exponential tail (SAMMY Iesopr=3).
207 ///
208 /// SAMMY Ref: `RslResolutionFunction_M.f90` getCo2, `rsl/mrsl4.f90` Wdsint.
209 delta_e_us: f64,
210}
211
212impl ResolutionParams {
213 /// Create validated resolution parameters.
214 ///
215 /// `delta_t_us` and `delta_l_m` are W-parameters (σ = W/√2), not standard
216 /// deviations. If your instrument numbers are 1σ or FWHM, use
217 /// [`from_sigma`](Self::from_sigma) or [`from_fwhm`](Self::from_fwhm)
218 /// instead of converting by hand.
219 ///
220 /// # Arguments
221 /// * `flight_path_m` — Flight path length in meters (must be > 0).
222 /// * `delta_t_us` — Timing width in microseconds, W-parameter (must be >= 0).
223 /// * `delta_l_m` — Flight path width in meters, W-parameter (must be >= 0).
224 /// * `delta_e_us` — Exponential tail parameter in SAMMY Deltae units
225 /// (must be >= 0). When 0, pure Gaussian broadening is used.
226 ///
227 /// # Errors
228 /// Returns `ResolutionParamsError::InvalidFlightPath` if `flight_path_m <= 0.0`
229 /// or is not finite.
230 /// Returns `ResolutionParamsError::InvalidDeltaT` if `delta_t_us < 0.0` or is
231 /// not finite.
232 /// Returns `ResolutionParamsError::InvalidDeltaL` if `delta_l_m < 0.0` or is
233 /// not finite.
234 /// Returns `ResolutionParamsError::InvalidDeltaE` if `delta_e_us < 0.0` or is
235 /// not finite.
236 pub fn new(
237 flight_path_m: f64,
238 delta_t_us: f64,
239 delta_l_m: f64,
240 delta_e_us: f64,
241 ) -> Result<Self, ResolutionParamsError> {
242 if !flight_path_m.is_finite() || flight_path_m <= 0.0 {
243 return Err(ResolutionParamsError::InvalidFlightPath(flight_path_m));
244 }
245 if !delta_t_us.is_finite() || delta_t_us < 0.0 {
246 return Err(ResolutionParamsError::InvalidDeltaT(delta_t_us));
247 }
248 if !delta_l_m.is_finite() || delta_l_m < 0.0 {
249 return Err(ResolutionParamsError::InvalidDeltaL(delta_l_m));
250 }
251 if !delta_e_us.is_finite() || delta_e_us < 0.0 {
252 return Err(ResolutionParamsError::InvalidDeltaE(delta_e_us));
253 }
254 Ok(Self {
255 flight_path_m,
256 delta_t_us,
257 delta_l_m,
258 delta_e_us,
259 })
260 }
261
262 /// Create resolution parameters from standard deviations.
263 ///
264 /// Instrument metrology is usually quoted as a 1σ jitter, while this type
265 /// stores W-parameters. This constructor applies W = σ·√2 to the timing and
266 /// flight-path terms, so a measured 1σ can be passed in directly.
267 ///
268 /// `delta_e_us` is not a Gaussian width — it is SAMMY's exponential tail
269 /// parameter Deltae — so it is passed through unchanged.
270 ///
271 /// # Errors
272 /// Same as [`new`](Self::new), on the converted values.
273 pub fn from_sigma(
274 flight_path_m: f64,
275 sigma_t_us: f64,
276 sigma_l_m: f64,
277 delta_e_us: f64,
278 ) -> Result<Self, ResolutionParamsError> {
279 Self::new(
280 flight_path_m,
281 sigma_t_us * SQRT_2,
282 sigma_l_m * SQRT_2,
283 delta_e_us,
284 )
285 }
286
287 /// Create resolution parameters from full widths at half maximum.
288 ///
289 /// Applies W = FWHM/(2·√(ln 2)) to the timing and flight-path terms. This
290 /// is the measure SAMMY's `Deltag` uses, so a value taken from a SAMMY
291 /// `.inp` timing field can be passed in directly.
292 ///
293 /// `delta_e_us` is SAMMY's exponential tail parameter, not a Gaussian
294 /// width, so it is passed through unchanged.
295 ///
296 /// # Errors
297 /// Same as [`new`](Self::new), on the converted values.
298 pub fn from_fwhm(
299 flight_path_m: f64,
300 fwhm_t_us: f64,
301 fwhm_l_m: f64,
302 delta_e_us: f64,
303 ) -> Result<Self, ResolutionParamsError> {
304 Self::new(
305 flight_path_m,
306 fwhm_t_us / FWHM_PER_W,
307 fwhm_l_m / FWHM_PER_W,
308 delta_e_us,
309 )
310 }
311
312 /// Returns the flight path length in meters.
313 #[must_use]
314 pub fn flight_path_m(&self) -> f64 {
315 self.flight_path_m
316 }
317
318 /// Total timing width in microseconds, as a W-parameter (σ = W/√2).
319 ///
320 /// The factor of 2 in [`gaussian_width()`](Self::gaussian_width) comes from
321 /// the energy-TOF derivative dE/E = 2·dt/t, not from a width-measure
322 /// conversion — the convention is unchanged between time and energy.
323 #[must_use]
324 pub fn delta_t_us(&self) -> f64 {
325 self.delta_t_us
326 }
327
328 /// Returns the flight path width in meters, as a W-parameter (σ = W/√2).
329 #[must_use]
330 pub fn delta_l_m(&self) -> f64 {
331 self.delta_l_m
332 }
333
334 /// Returns the exponential tail parameter (SAMMY Deltae units).
335 #[must_use]
336 pub fn delta_e_us(&self) -> f64 {
337 self.delta_e_us
338 }
339
340 /// Whether the exponential tail is active (Deltae > 0, SAMMY Iesopr=3).
341 #[must_use]
342 pub fn has_exponential_tail(&self) -> bool {
343 self.delta_e_us > NEAR_ZERO_FLOOR
344 }
345
346 /// Exponential tail width Widexp(E) in eV.
347 ///
348 /// SAMMY Ref: `rsl/mrsl4.f90` Wdsint lines 55-56 (Kedxfw=false path):
349 /// `Widexp = E * Co2 * sqrt(E)` where `Co2 = 2·Deltae / (Sm2·Dist)`.
350 ///
351 /// Combined: `Widexp = 2·Deltae·E^(3/2) / (TOF_FACTOR·L)`.
352 #[must_use]
353 pub fn exp_width(&self, energy_ev: f64) -> f64 {
354 if energy_ev <= 0.0 || self.delta_e_us <= 0.0 {
355 return 0.0;
356 }
357 2.0 * self.delta_e_us * energy_ev.powf(1.5) / (TOF_FACTOR * self.flight_path_m)
358 }
359
360 /// Gaussian resolution width W_g(E) in eV — the W of `exp(-x²/W_g²)`.
361 ///
362 /// Combines timing and flight-path contributions in quadrature:
363 /// W_g² = (2·Δt/t × E)² + (2·ΔL/L × E)²
364 ///
365 /// where t = TOF_FACTOR × L / √E is the time-of-flight in μs. Δt and ΔL are
366 /// W-parameters too, so the convention carries through unchanged; the
367 /// standard deviation is `W_g/√2` and the FWHM is [`fwhm`](Self::fwhm).
368 #[must_use]
369 pub fn gaussian_width(&self, energy_ev: f64) -> f64 {
370 if energy_ev <= 0.0 || self.flight_path_m <= 0.0 {
371 return 0.0;
372 }
373
374 // Timing contribution: W_t = 2 × Δt × E^(3/2) / (TOF_FACTOR × L)
375 let timing =
376 2.0 * self.delta_t_us * energy_ev.powf(1.5) / (TOF_FACTOR * self.flight_path_m);
377
378 // Path length contribution: W_L = 2 × ΔL × E / L
379 let path = 2.0 * self.delta_l_m * energy_ev / self.flight_path_m;
380
381 (timing * timing + path * path).sqrt()
382 }
383
384 /// FWHM of the resolution function at energy E, in eV.
385 ///
386 /// `FWHM = 2·√(ln 2)·W`, the conversion for `exp(-x²/W²)`. For the standard
387 /// deviation instead, use `gaussian_width(E)/√2`.
388 #[must_use]
389 pub fn fwhm(&self, energy_ev: f64) -> f64 {
390 FWHM_PER_W * self.gaussian_width(energy_ev)
391 }
392}
393
394/// Apply Gaussian resolution broadening to cross-section data.
395///
396/// Convolves the input cross-sections with a Gaussian kernel whose width
397/// varies with energy according to the instrument resolution function.
398///
399/// # Arguments
400/// * `energies` — Energy grid in eV (must be sorted ascending).
401/// * `cross_sections` — Cross-sections in barns at each energy point.
402/// * `params` — Resolution function parameters.
403///
404/// # Returns
405/// Resolution-broadened cross-sections on the same energy grid.
406///
407/// # Errors
408/// Returns [`ResolutionError::LengthMismatch`] if the arrays differ in length,
409/// or [`ResolutionError::UnsortedEnergies`] if the energy grid is not sorted
410/// in non-descending order.
411pub fn resolution_broaden(
412 energies: &[f64],
413 cross_sections: &[f64],
414 params: &ResolutionParams,
415) -> Result<Vec<f64>, ResolutionError> {
416 validate_inputs(energies, cross_sections)?;
417 Ok(resolution_broaden_presorted(
418 energies,
419 cross_sections,
420 params,
421 ))
422}
423
424/// Check that the energy grid is sorted and that its length matches the data.
425fn validate_inputs(energies: &[f64], data: &[f64]) -> Result<(), ResolutionError> {
426 if energies.len() != data.len() {
427 return Err(ResolutionError::LengthMismatch {
428 energies: energies.len(),
429 data: data.len(),
430 });
431 }
432 if !energies.windows(2).all(|w| w[0] <= w[1]) {
433 return Err(ResolutionError::UnsortedEnergies);
434 }
435 Ok(())
436}
437
438// ─── Xcoef quadrature weights ──────────────────────────────────────────────────
439
440/// Compute SAMMY's 4-point quadrature weights for a non-uniform energy grid.
441///
442/// Replaces the simple trapezoidal rule `de = (E[j+1] - E[j-1]) / 2` with
443/// SAMMY's higher-order scheme from Eq. IV B 3.8 (page 80 of SAMMY manual R3).
444///
445/// SAMMY Ref: `convolution/DopplerAndResolutionBroadener.cpp`, `setXcoefWeights()`.
446///
447/// The weights include a correction term x2(k) that accounts for non-uniform
448/// grid spacing, providing 4th-order accuracy on smooth grids.
449///
450/// Note: the returned weights are 12x the quantity in Eq. IV B 3.8. This
451/// constant factor cancels during normalization (sum/norm), so the broadened
452/// result is independent of the scaling.
453fn compute_xcoef_weights(energies: &[f64]) -> Vec<f64> {
454 let n = energies.len();
455 if n == 0 {
456 return vec![];
457 }
458 if n == 1 {
459 return vec![1.0];
460 }
461
462 // SAMMY's 4-point quadrature weights (Eq. IV B 3.8, SAMMY Manual R3 p80).
463 //
464 // Uses a sliding window of 5 consecutive energies E[0..4] to compute
465 // coefficients A[0..5] at each grid point k:
466 //
467 // A[0] = v1 (k >= 2)
468 // A[1] = 5·v2 (k >= 1)
469 // A[2] = 5·v3 (k < n-1)
470 // A[3] = v4 (k < n-2)
471 // A[4] = (v3² - v1²)/v2 curvature correction (k >= 2)
472 // A[5] = -(v4² - v2²)/v3 curvature correction (k >= 1)
473 //
474 // where v1..v4 are consecutive grid spacings around point k.
475 //
476 // The result is 12× Eq. IV B 3.8; this constant factor cancels during
477 // normalization (sum/norm) in the broadening loop.
478 //
479 // SAMMY Ref: `convolution/DopplerAndResolutionBroadener.cpp` lines 365-457
480 let mut weights = vec![0.0f64; n];
481
482 // Sliding window: e[j] holds energies relative to current k.
483 // At loop start for k: e[0]=E[k-2], e[1]=E[k-1], e[2]=E[k],
484 // e[3]=E[k+1], e[4]=E[k+2]
485 // Out-of-bounds positions are 0.0 (matching SAMMY's convention).
486 let mut e = [0.0f64; 5];
487 e[3] = energies[0];
488 if n > 1 {
489 e[4] = energies[1];
490 }
491
492 for k in 0..n {
493 // Shift window left.
494 e[0] = e[1];
495 e[1] = e[2];
496 e[2] = e[3];
497 e[3] = e[4];
498 e[4] = if k + 2 < n { energies[k + 2] } else { 0.0 };
499
500 let v1 = e[1] - e[0];
501 let v2 = e[2] - e[1];
502 let v3 = e[3] - e[2];
503 let v4 = e[4] - e[3];
504
505 let mut a = [0.0f64; 6];
506
507 if k >= 2 {
508 a[0] = v1;
509 // Curvature correction: x2(k-2) = (v3² - v1²) / v2
510 if v2.abs() > NEAR_ZERO_FLOOR {
511 a[4] = (v3 * v3 - v1 * v1) / v2;
512 }
513 }
514 if k >= 1 {
515 a[1] = 5.0 * v2;
516 // Curvature correction: -x2(k-1) = -(v4² - v2²) / v3
517 if v3.abs() > NEAR_ZERO_FLOOR {
518 a[5] = -(v4 * v4 - v2 * v2) / v3;
519 }
520 }
521 if k != n - 1 {
522 a[2] = 5.0 * v3;
523 }
524 if k < n.saturating_sub(2) {
525 a[3] = v4;
526 }
527
528 // Boundary overrides (SAMMY source lines 446-450).
529 if k == n.saturating_sub(2) {
530 a[5] = 0.0;
531 }
532 if k == n - 1 {
533 a[4] = 0.0;
534 a[5] = 0.0;
535 }
536
537 weights[k] = a.iter().sum::<f64>();
538 }
539
540 weights
541}
542
543/// Complementary error function erfc(x) = 1 − erf(x).
544///
545/// erfc(x) = exp(-x²) · exerfc(x) / √π for x ≥ 0, and
546/// erfc(x) = 2 − erfc(−x) for x < 0.
547pub(crate) fn erfc(x: f64) -> f64 {
548 const SQRT_PI: f64 = 1.772_453_850_905_516;
549 if x >= 0.0 {
550 (-x * x).exp() * exerfc(x) / SQRT_PI
551 } else {
552 let xp = -x;
553 2.0 - (-xp * xp).exp() * exerfc(xp) / SQRT_PI
554 }
555}
556
557// ─── Scaled complementary error function ───────────────────────────────────────
558
559/// Compute exp(x²)·erfc(x)·√π, numerically stable for all x.
560///
561/// SAMMY Ref: `fnc/exerfc.f90`.
562///
563/// Uses rational approximation for 0 < x ≤ 5.01 and asymptotic expansion
564/// (Abramowitz & Stegun 7.1.23) for x > 5.01. For x < 0 it uses
565/// erfc(x) = 2 − erfc(−x): exerfc(x) = 2√π·exp(x²) − exerfc(−x).
566pub(crate) fn exerfc(x: f64) -> f64 {
567 const SQRT_PI: f64 = 1.772_453_850_905_516;
568 const TWO_SQRT_PI: f64 = 3.544_907_701_811_032;
569 const XMAX: f64 = 5.01;
570 // Rational approximation coefficients (from SAMMY's exerfc.f90)
571 const A1: f64 = 8.584_076_57e-1;
572 const A2: f64 = 3.078_181_93e-1;
573 const A3: f64 = 6.383_238_91e-2;
574 const A4: f64 = 1.824_050_75e-4;
575 const A5: f64 = 6.509_742_65e-1;
576 const A6: f64 = 2.294_848_19e-1;
577 const A7: f64 = 3.403_018_23e-2;
578
579 if x < 0.0 {
580 TWO_SQRT_PI * (x * x).exp() - exerfc(-x)
581 } else if x > XMAX {
582 asympt(x)
583 } else if x > 0.0 {
584 let a = (A1 + x * (A2 + x * (A3 - x * A4))) / (1.0 + x * (A5 + x * (A6 + x * A7)));
585 let b = SQRT_PI + x * (2.0 - a);
586 b / (x * b + 1.0)
587 } else {
588 SQRT_PI
589 }
590}
591
592/// Asymptotic expansion of exp(x²)·erfc(x)·√π for large positive x.
593///
594/// SAMMY Ref: `fnc/exerfc.f90`, Asympt function.
595/// Uses Abramowitz & Stegun 7.1.23.
596fn asympt(x: f64) -> f64 {
597 if x == 0.0 {
598 return 0.0;
599 }
600 let e = 1.0 / x;
601 if e == 0.0 {
602 return 0.0;
603 }
604 let b = 1.0 / (x * x);
605 let mut a = 1.0;
606 let mut c = b * 0.5;
607 for n in 1..=40 {
608 a -= c;
609 c *= -(n as f64 + 0.5) * b;
610 if (a - c) == a || (c / a).abs() < 1e-8 {
611 break;
612 }
613 }
614 a * e
615}
616
617/// Compute the Gaussian+exponential combined kernel weight Z(A, B).
618///
619/// Returns √π · exp(-A² + B²) · erfc(B), computed via exerfc for stability.
620///
621/// SAMMY Ref: `rsl/mrsl1.f90` lines 467-484 (Resbrd, Iesopr=3 path).
622///
623/// When B >= 0: `Z = exp(-A²) · Exerfc(B)`
624/// When B < 0: `Z = 2√π · exp(B² − A²) − exp(−A²) · Exerfc(−B)`, from
625/// erfc(B) = 2 − erfc(−B), with exp(B² − A²) taken as one exponential so
626/// that it does not underflow where exp(−A²) does.
627///
628/// SAMMY Ref: `fnc/xxerfc.f90`.
629fn gauss_exp_kernel(a: f64, b: f64) -> f64 {
630 const TWO_SQRT_PI: f64 = 3.544_907_701_811_032;
631 let exp_neg_a2 = (-a * a).exp();
632 if b >= 0.0 {
633 if exp_neg_a2 == 0.0 {
634 return 0.0;
635 }
636 exp_neg_a2 * exerfc(b)
637 } else {
638 TWO_SQRT_PI * (b * b - a * a).exp() - exp_neg_a2 * exerfc(-b)
639 }
640}
641
642/// Compute the energy shift for the Gaussian+exponential kernel peak.
643///
644/// Finds the peak of the combined kernel relative to E=0 via Newton-Raphson
645/// iteration. This centers the convolution window on the kernel maximum.
646///
647/// SAMMY Ref: `rsl/mrsl5.f90`, Shftge function.
648///
649/// # Arguments
650/// * `c` — Mixing parameter: Widgau / (2·Widexp)
651/// * `widgau` — Gaussian resolution width (eV)
652///
653/// # Returns
654/// The energy shift Est (eV) to apply to the measurement energy.
655fn shftge(c: f64, widgau: f64) -> f64 {
656 const ONE_OVER_SQRT_PI: f64 = 0.564_189_583_547_756_3;
657 const SMALL: f64 = 0.01;
658
659 let ax = c;
660 let bx = widgau;
661
662 // Initial guess
663 let mut x0 = if ax > ONE_OVER_SQRT_PI { ax } else { 0.0 };
664
665 let f0_initial = ax * exerfc(x0) - 1.0;
666 let mut f0 = f0_initial;
667 let fff = f0;
668
669 for _iter in 0..100 {
670 let f = ax * exerfc(x0) - 1.0;
671 let xma = x0 - ax;
672 let q = 1.0 - 2.0 * x0 * xma;
673 let delx = if q.abs() < NEAR_ZERO_FLOOR {
674 // q ≈ 0: division would overflow; accept current estimate.
675 break;
676 } else if xma * xma - q * f > 0.0 {
677 let disc = (xma * xma - q * f).sqrt();
678 if xma > 0.0 {
679 (-xma + disc) / q
680 } else {
681 (-xma - disc) / q
682 }
683 } else {
684 if xma.abs() < NEAR_ZERO_FLOOR {
685 break;
686 }
687 -f * 0.5 / xma
688 };
689 let x1 = x0 + delx;
690 let shftg = (ax - x1) * bx;
691 if (x1 - x0).abs() / x1.abs().max(1.0) < SMALL
692 && fff.abs() > NEAR_ZERO_FLOOR
693 && (f - f0).abs() / fff.abs() < SMALL
694 {
695 return shftg;
696 }
697 f0 = f;
698 x0 = x1;
699 }
700
701 (ax - x0) * bx
702}
703
704/// Threshold for the ratio C = W_g / (2·W_e) above which the exponential
705/// tail is negligible and the pure Gaussian PW-linear path is used instead.
706///
707/// At C = 2.5, erfc(2.5) ≈ 0.0005, so the exp tail contributes <0.05% of the
708/// kernel integral. Using the pure Gaussian path at this threshold introduces
709/// negligible systematic error while enabling the more accurate PW-linear
710/// integration and adaptive intermediate point insertion.
711const EXP_TAIL_NEGLIGIBLE_C: f64 = 2.5;
712
713/// Resolution broadening assuming the energy grid is already validated
714/// (sorted ascending, same length as cross_sections).
715///
716/// For each broadening energy, selects the optimal integration method:
717/// - **PW-linear Gaussian** (exact, second-order): when `delta_e == 0` or
718/// the ratio C = W_g/(2·W_e) > [`EXP_TAIL_NEGLIGIBLE_C`] (exp tail negligible).
719/// - **Combined Gaussian+exp kernel** with SAMMY Xcoef quadrature: when the
720/// exponential tail is significant (C ≤ threshold).
721///
722/// SAMMY Ref: `rsl/mrsl1.f90` Resbrd, `convolution/DopplerAndResolutionBroadener.cpp`
723pub(crate) fn resolution_broaden_presorted(
724 energies: &[f64],
725 cross_sections: &[f64],
726 params: &ResolutionParams,
727) -> Vec<f64> {
728 let n = energies.len();
729 if n == 0 {
730 return vec![];
731 }
732
733 // Precompute Xcoef weights (used only by the combined kernel path).
734 // Even if some energies take the PW-linear path, we compute weights for
735 // the full grid — cheaper than branching per-energy.
736 let xcoef = if params.has_exponential_tail() {
737 compute_xcoef_weights(energies)
738 } else {
739 vec![]
740 };
741 let n_sigma = 5.0; // Integrate out to 5σ for Gaussian
742 let mut broadened = vec![0.0f64; n];
743
744 for i in 0..n {
745 let e = energies[i];
746 let widgau = params.gaussian_width(e);
747
748 if widgau < NEAR_ZERO_FLOOR {
749 broadened[i] = cross_sections[i];
750 continue;
751 }
752
753 // Per-energy decision: use combined kernel only when the exp tail
754 // is significant at THIS energy.
755 let widexp = params.exp_width(e);
756 let use_combined =
757 widexp > NEAR_ZERO_FLOOR && widgau / (2.0 * widexp) <= EXP_TAIL_NEGLIGIBLE_C;
758
759 // Compute integration limits.
760 let (e_low, e_high) = if use_combined {
761 // SAMMY Ref: mrsl4.f90 lines 57-65
762 let wlow = n_sigma * widgau;
763 let rwid = widgau / widexp;
764 let wup = if rwid <= 1.0 {
765 6.25 * widexp
766 } else if rwid <= 2.0 {
767 n_sigma * (3.0 - rwid) * widgau
768 } else {
769 n_sigma * widgau
770 };
771 (e - wlow, e + wup)
772 } else {
773 (e - n_sigma * widgau, e + n_sigma * widgau)
774 };
775
776 let j_lo = energies.partition_point(|&ej| ej < e_low);
777 let j_hi = energies.partition_point(|&ej| ej <= e_high);
778
779 if j_hi.saturating_sub(j_lo) <= 1 {
780 broadened[i] = cross_sections[i];
781 continue;
782 }
783
784 let mut sum = 0.0;
785 let mut norm = 0.0;
786
787 if use_combined {
788 // Combined Gaussian + exponential kernel (SAMMY Iesopr=3)
789 // with 4-point Xcoef quadrature weights.
790 // SAMMY Ref: mrsl1.f90 lines 455-484
791 let c = widgau * 0.5 / widexp;
792 let est = shftge(c, widgau);
793 let y = c * widgau + e - est;
794
795 for j in j_lo..j_hi {
796 let ee = energies[j];
797 let a = (e - est - ee) / widgau;
798 let b = (y - ee) / widgau;
799 let z = gauss_exp_kernel(a, b);
800 let wt = xcoef[j] * z;
801 sum += wt * cross_sections[j];
802 norm += wt;
803 }
804 } else {
805 // Pure Gaussian kernel with piecewise-linear exact integration.
806 //
807 // For each interval [E_j, E_{j+1}], integrate G(E_i - E') × σ_linear(E')
808 // exactly, where G(x) = exp(-x²/W²) / (W√π).
809 //
810 // Substituting u = (E' - E_i)/W, dE' = W du:
811 // ∫ G × [σ_j + slope×(E'-E_j)] dE'
812 // = (1/√π) ∫ exp(-u²) [σ_j + slope×W×(u - a_j)] du
813 //
814 // With I₀ = erf(a_{j+1}) - erf(a_j) and
815 // I₁ = (exp(-a_j²) - exp(-a_{j+1}²)) / 2:
816 //
817 // The normalization integral is I₀/2, so after sum/norm (2 cancels):
818 // sum += σ_j × I₀ + slope × W × (2/√π × I₁ - a_j × I₀)
819 // norm += I₀
820 //
821 // The factor 2/√π on I₁ comes from the u·exp(-u²) integral
822 // needing to match the normalization convention erf(x) = 2/√π ∫ exp(-t²) dt.
823 const TWO_OVER_SQRT_PI: f64 = std::f64::consts::FRAC_2_SQRT_PI;
824 let inv_w = 1.0 / widgau;
825 for j in j_lo..j_hi.saturating_sub(1) {
826 let e_j = energies[j];
827 let e_j1 = energies[j + 1];
828 let h = e_j1 - e_j;
829 if h < NEAR_ZERO_FLOOR {
830 continue;
831 }
832
833 let a_j = (e_j - e) * inv_w;
834 let a_j1 = (e_j1 - e) * inv_w;
835
836 // I₀ = erf(a_{j+1}) - erf(a_j) = erfc(a_j) - erfc(a_{j+1})
837 let erfc_aj = erfc(a_j);
838 let erfc_aj1 = erfc(a_j1);
839 let i0 = erfc_aj - erfc_aj1;
840
841 if i0 < NEAR_ZERO_FLOOR {
842 continue;
843 }
844
845 // I₁ = (exp(-a_j²) - exp(-a_{j+1}²)) / 2
846 let i1 = ((-a_j * a_j).exp() - (-a_j1 * a_j1).exp()) * 0.5;
847
848 let slope = (cross_sections[j + 1] - cross_sections[j]) / h;
849
850 // σ_j × I₀ + slope × W × (2/√π × I₁ - a_j × I₀)
851 sum += cross_sections[j] * i0 + slope * widgau * (TWO_OVER_SQRT_PI * i1 - a_j * i0);
852 norm += i0;
853 }
854 }
855
856 if norm > DIVISION_FLOOR {
857 broadened[i] = sum / norm;
858 } else {
859 broadened[i] = cross_sections[i];
860 }
861 }
862
863 broadened
864}
865
866/// A tabulated resolution function from Monte Carlo instrument simulation.
867///
868/// Contains reference kernels R(Δt; E_ref) at discrete energies, stored in
869/// TOF-offset space (μs). Kernels are interpolated between reference energies
870/// and converted from TOF to energy space when applied.
871///
872/// ## Offset orientation
873///
874/// Positive `Δt` = delayed emission (the moderator storage tail); the
875/// kernel mode sits at `Δt = 0`. At apply time the broadener gathers
876/// theory at `t − Δt` (convolution — see [`Self::broaden`]), so the
877/// positive-`Δt` tail reads theory from earlier TOF = higher energy and
878/// broadened dips acquire their tail toward lower apparent energy.
879///
880/// ## File Format (VENUS/FTS)
881///
882/// ```text
883/// FTS BL10 case i00dd folded triang FWHM 350 ns PSR ← header
884/// ----- ← separator
885/// 5.00000e-004 0.00000e+000 ← energy block start
886/// -53.458917835671329 2.051764258257523e-04 ← (tof_offset_μs, weight)
887/// ...
888/// ← blank line separates blocks
889/// 1.00000e-003 0.00000e+000 ← next energy block
890/// ...
891/// ```
892#[derive(Debug, Clone)]
893pub struct TabulatedResolution {
894 /// Reference energies (eV), sorted ascending.
895 ///
896 /// Shared: a fit with a free `L_scale` rebinds the flight path on every
897 /// forward evaluation, which must cost a reference count, not a copy.
898 ref_energies: Arc<Vec<f64>>,
899 /// For each reference energy: (tof_offsets_μs, weights) pairs.
900 /// Weights are peak-normalized (max=1.0).
901 ///
902 /// Shared for the same reason as `ref_energies`.
903 kernels: Arc<Vec<(Vec<f64>, Vec<f64>)>>,
904 /// Flight path length in meters (needed for TOF↔energy conversion).
905 flight_path_m: f64,
906}
907
908/// Trapezoidal-weighted centroid and RMS width of one kernel block.
909///
910/// The `dt` weights match the quadrature `broaden_presorted` integrates
911/// with (single point → 1.0; edges → one-sided span; interior → half
912/// the neighbour span), so these are the moments the broadener
913/// effectively applies: zero-weight entries contribute nothing
914/// (`tw = 0`, matching the broadener's `w <= 0` skip) and negative
915/// weights are rejected at construction, so the integration domains
916/// coincide exactly. The centroid pass is byte-identical to the
917/// was factored out. Returns `(centroid, sigma)`; `sigma` is `0.0` for
918/// a single-point or zero-mass block — callers treat a non-positive or
919/// non-finite `sigma` as degenerate.
920fn trapezoidal_moments(offsets: &[f64], weights: &[f64]) -> (f64, f64) {
921 let n_k = offsets.len();
922 let dt_width = |k: usize| -> f64 {
923 if n_k <= 1 {
924 1.0
925 } else if k == 0 {
926 offsets[1] - offsets[0]
927 } else if k == n_k - 1 {
928 offsets[k] - offsets[k - 1]
929 } else {
930 (offsets[k + 1] - offsets[k - 1]) * 0.5
931 }
932 };
933 let (mut cnum, mut cden) = (0.0, 0.0);
934 for (k, (&o, &w)) in offsets.iter().zip(weights).enumerate() {
935 let tw = w * dt_width(k).abs();
936 cnum += o * tw;
937 cden += tw;
938 }
939 let centroid = if cden > 0.0 { cnum / cden } else { 0.0 };
940 let mut m2 = 0.0;
941 for (k, (&o, &w)) in offsets.iter().zip(weights).enumerate() {
942 let tw = w * dt_width(k).abs();
943 m2 += (o - centroid).powi(2) * tw;
944 }
945 let sigma = if cden > 0.0 { (m2 / cden).sqrt() } else { 0.0 };
946 (centroid, sigma)
947}
948
949/// Integrate a sampled probability distribution into adjacent requested
950/// edges. A one-point kernel is treated as a delta mass at that point; longer
951/// kernels are piecewise-linear densities.
952///
953/// The density is normalized over its complete supplied support. The result
954/// is deliberately not renormalized to `edges`: a requested detector window
955/// that covers only part of the supplied pulse therefore sums to less than
956/// one.
957pub(crate) fn piecewise_linear_bin_probabilities(
958 times: &[f64],
959 weights: &[f64],
960 edges: &[f64],
961) -> Option<Vec<f64>> {
962 if times.is_empty() || times.len() != weights.len() {
963 return None;
964 }
965 // A duplicated (or decreasing) time gives a zero-width segment whose
966 // slope is ±inf/NaN inside the CDF interpolation when an edge lands
967 // there — and a NaN time passes a pure monotonicity check (every NaN
968 // comparison is false) only to underflow `hi - 1` in the CDF closure
969 // (`partition_point` returns 0 when the first predicate is false).
970 // Validate finiteness and strict ordering of BOTH coordinate arrays up
971 // front; NaN weights are caught downstream by the total-mass check.
972 if times.iter().any(|t| !t.is_finite()) || times.windows(2).any(|w| w[1] <= w[0]) {
973 return None;
974 }
975 if edges.iter().any(|e| !e.is_finite()) || edges.windows(2).any(|w| w[1] <= w[0]) {
976 return None;
977 }
978
979 if times.len() == 1 {
980 if !times[0].is_finite() || !weights[0].is_finite() || weights[0] <= 0.0 {
981 return None;
982 }
983 let mut probabilities = vec![0.0; edges.len().saturating_sub(1)];
984 for (index, edge) in edges.windows(2).enumerate() {
985 let in_bin = edge[0] <= times[0]
986 && (times[0] < edge[1]
987 || (index + 1 == probabilities.len() && times[0] == edge[1]));
988 if in_bin {
989 probabilities[index] = 1.0;
990 break;
991 }
992 }
993 return Some(probabilities);
994 }
995
996 let mut cumulative = Vec::with_capacity(times.len());
997 cumulative.push(0.0);
998 for i in 0..times.len() - 1 {
999 let width = times[i + 1] - times[i];
1000 let area = 0.5 * (weights[i] + weights[i + 1]) * width;
1001 cumulative.push(cumulative[i] + area.max(0.0));
1002 }
1003 let total = *cumulative.last()?;
1004 if !total.is_finite() || total <= 0.0 {
1005 return None;
1006 }
1007 let cdf = |x: f64| -> f64 {
1008 if x <= times[0] {
1009 return 0.0;
1010 }
1011 if x >= times[times.len() - 1] {
1012 return 1.0;
1013 }
1014 let hi = times.partition_point(|&time| time <= x);
1015 let lo = hi - 1;
1016 let width = times[hi] - times[lo];
1017 let dx = x - times[lo];
1018 let slope = (weights[hi] - weights[lo]) / width;
1019 let partial = weights[lo] * dx + 0.5 * slope * dx * dx;
1020 ((cumulative[lo] + partial) / total).clamp(0.0, 1.0)
1021 };
1022
1023 Some(
1024 edges
1025 .windows(2)
1026 .map(|edge| (cdf(edge[1]) - cdf(edge[0])).max(0.0))
1027 .collect(),
1028 )
1029}
1030
1031impl TabulatedResolution {
1032 /// Reference energies (eV), sorted ascending.
1033 pub fn ref_energies(&self) -> &[f64] {
1034 &self.ref_energies
1035 }
1036
1037 /// For each reference energy: (tof_offsets_μs, weights) pairs.
1038 /// Weights are peak-normalized (max=1.0).
1039 pub fn kernels(&self) -> &[(Vec<f64>, Vec<f64>)] {
1040 &self.kernels
1041 }
1042
1043 /// Flight path length in meters (needed for TOF↔energy conversion).
1044 pub fn flight_path_m(&self) -> f64 {
1045 self.flight_path_m
1046 }
1047
1048 /// Probability that a neutron of known true energy is recorded in each
1049 /// supplied detector-time bin.
1050 ///
1051 /// The tabulated pulse is selected and interpolated at `true_energy_ev`;
1052 /// an energy outside the tabulated reference range uses the nearest
1053 /// reference kernel unchanged (the same clamping the broadening path
1054 /// applies). Its offsets are relative to the reference pulse mode, so
1055 /// the nominal arrival is
1056 /// `timing_offset_us + TOF_FACTOR * flight_path_m / sqrt(E)`.
1057 /// `timing_offset_us` is the effective clock/energy-axis offset calibrated
1058 /// for the measurement; this method does not invent an absolute moderator
1059 /// emission time that is absent from a mode-centred UDR file.
1060 ///
1061 /// The returned vector has one entry per adjacent edge pair and is not
1062 /// renormalized to the supplied window. Probability outside the measured
1063 /// window remains outside it; the quantified acquisition-window loss at
1064 /// this energy is one minus the sum of the returned vector.
1065 ///
1066 /// # Errors
1067 /// Returns [`ResolutionParseError::InvalidFormat`] unless the true energy
1068 /// is positive and finite, the timing offset is finite, and at least two
1069 /// finite bin edges are supplied in strictly increasing order. It also
1070 /// fails if the interpolated tabulated pulse has zero area.
1071 pub fn detector_bin_probabilities(
1072 &self,
1073 true_energy_ev: f64,
1074 detector_time_edges_us: &[f64],
1075 timing_offset_us: f64,
1076 ) -> Result<Vec<f64>, ResolutionParseError> {
1077 if !true_energy_ev.is_finite() || true_energy_ev <= 0.0 {
1078 return Err(ResolutionParseError::InvalidFormat(format!(
1079 "true energy must be positive and finite, got {true_energy_ev}"
1080 )));
1081 }
1082 if !timing_offset_us.is_finite() {
1083 return Err(ResolutionParseError::InvalidFormat(format!(
1084 "timing_offset_us must be finite, got {timing_offset_us}"
1085 )));
1086 }
1087 if detector_time_edges_us.len() < 2
1088 || detector_time_edges_us.iter().any(|edge| !edge.is_finite())
1089 || detector_time_edges_us
1090 .windows(2)
1091 .any(|edge| edge[0] >= edge[1])
1092 {
1093 return Err(ResolutionParseError::InvalidFormat(
1094 "detector time edges must contain at least two finite, strictly increasing values"
1095 .to_string(),
1096 ));
1097 }
1098
1099 let nominal_arrival =
1100 timing_offset_us + TOF_FACTOR * self.flight_path_m / true_energy_ev.sqrt();
1101 let relative_edges: Vec<f64> = detector_time_edges_us
1102 .iter()
1103 .map(|edge| edge - nominal_arrival)
1104 .collect();
1105 let (times, weights) = self.interpolated_kernel(true_energy_ev);
1106 piecewise_linear_bin_probabilities(×, &weights, &relative_edges).ok_or_else(|| {
1107 ResolutionParseError::InvalidFormat(format!(
1108 "tabulated resolution at E = {true_energy_ev} eV has zero sampled \
1109 area or a degenerate interpolated kernel"
1110 ))
1111 })
1112 }
1113
1114 /// Width-corrected copy of this tabulated kernel.
1115 ///
1116 /// Shape-preserving instrument-resolution calibration knob: each
1117 /// reference-energy block's TOF offsets are scaled by
1118 /// `s(E) = s0 · (E / e_ref)^p` **about the block's intensity centroid**, so
1119 /// the kernel widens/narrows without moving its centroid — width and position
1120 /// stay orthogonal (`t0`/`L` handle absolute position). Weights are unchanged;
1121 /// the apply-time trapezoidal renormalization preserves unit area.
1122 ///
1123 /// Exactness note: the orthogonality is exact **at reference
1124 /// energies**. Between references, `interpolated_kernel`'s
1125 /// width-normalized blend re-scales each block about the mode
1126 /// (offset 0), so the applied centroid picks up a second-order
1127 /// dependence on the width exponent `p` (measured ~1 % of σ for
1128 /// |p| ≤ 0.1 on widely spaced references) — absorbed by the
1129 /// jointly fitted `t0` in calibration.
1130 ///
1131 /// The pivot is the **trapezoidal-weighted** centroid `Σ o·w·dt / Σ w·dt`,
1132 /// using the *same* `dt` quadrature weights as the broadening integral (see
1133 /// [`Self::broaden`]). Because `dt` is itself affine in the offsets, the width
1134 /// scale multiplies every `dt` by `s`, so the integrated centroid is preserved
1135 /// exactly on **any** offset grid (uniform or not) — not just on uniform grids
1136 /// where the trapezoidal and plain centroids happen to coincide.
1137 ///
1138 /// `s0 = 1, p = 0` returns a width-identical copy. This is the fittable model
1139 /// behind the `udr_corr` resolution-calibration family: it trusts the
1140 /// Monte-Carlo *shape* and calibrates only its width / energy-dependence.
1141 ///
1142 /// # Errors
1143 /// Returns [`ResolutionError::InvalidWidthCorrection`] unless `s0` is finite
1144 /// and `> 0`, `e_ref` is finite and `> 0`, and `p` is finite. A non-positive
1145 /// `s0` would reverse/collapse the (ascending) offset ordering the broadening
1146 /// loop assumes, so it is rejected up front rather than silently clamped.
1147 pub fn width_corrected(
1148 &self,
1149 s0: f64,
1150 p: f64,
1151 e_ref: f64,
1152 ) -> Result<TabulatedResolution, ResolutionError> {
1153 if !(s0.is_finite() && s0 > 0.0 && e_ref.is_finite() && e_ref > 0.0 && p.is_finite()) {
1154 return Err(ResolutionError::InvalidWidthCorrection { s0, p, e_ref });
1155 }
1156 let kernels = self
1157 .ref_energies
1158 .iter()
1159 .zip(self.kernels.iter())
1160 .map(|(&e, (offsets, weights))| {
1161 // The power law can overflow `s` to ±∞ for finite-but-extreme `p`;
1162 // reject up front rather than build a non-finite kernel directly
1163 // (which would bypass `from_kernels`' finiteness check).
1164 let s = s0 * (e / e_ref).powf(p);
1165 if !(s.is_finite() && s > 0.0) {
1166 return Err(ResolutionError::InvalidWidthCorrection { s0, p, e_ref });
1167 }
1168 // Pivot about the trapezoidal-weighted centroid (matching the
1169 // `dt`-weighting in `broaden_presorted`), so the *integrated*
1170 // centroid is preserved on non-uniform offset grids — not only on
1171 // uniform grids where this reduces to the plain centroid.
1172 let (centroid, _) = trapezoidal_moments(offsets, weights);
1173 let scaled = offsets
1174 .iter()
1175 .map(|&o| centroid + s * (o - centroid))
1176 .collect();
1177 Ok((scaled, weights.clone()))
1178 })
1179 .collect::<Result<Vec<_>, ResolutionError>>()?;
1180 Ok(TabulatedResolution {
1181 ref_energies: self.ref_energies.clone(),
1182 kernels: Arc::new(kernels),
1183 flight_path_m: self.flight_path_m,
1184 })
1185 }
1186
1187 /// The same kernel table read against a different flight path.
1188 ///
1189 /// The stored offsets are times relative to this table's own anchor, and
1190 /// the flight path enters only the TOF↔energy map they are applied
1191 /// through. So rebinding it is exact and needs no resynthesis — the kernel
1192 /// itself is a property of the moderator, not of how far the neutron then
1193 /// flew.
1194 ///
1195 /// This is what a fitted `L_scale` requires: the data's energy grid is
1196 /// built with `L·L_scale`, and a kernel still reading `L` applies a width
1197 /// wrong by that same factor.
1198 ///
1199 /// # Errors
1200 /// Returns [`ResolutionParseError::InvalidFormat`] if `flight_path_m` is
1201 /// not positive and finite.
1202 pub fn with_flight_path(&self, flight_path_m: f64) -> Result<Self, ResolutionParseError> {
1203 if !flight_path_m.is_finite() || flight_path_m <= 0.0 {
1204 return Err(ResolutionParseError::InvalidFormat(format!(
1205 "Flight path must be a positive finite number, got {flight_path_m}"
1206 )));
1207 }
1208 Ok(Self {
1209 ref_energies: Arc::clone(&self.ref_energies),
1210 kernels: Arc::clone(&self.kernels),
1211 flight_path_m,
1212 })
1213 }
1214
1215 /// Kernel support at energy `e_ev`, in eV: the larger of the two distances
1216 /// in [`Self::gather_bounds_ev`]. Returns `0.0` for non-positive or
1217 /// non-finite `e_ev`, an empty kernel set, or a non-positive flight path.
1218 #[must_use]
1219 pub fn kernel_support_ev(&self, e_ev: f64) -> f64 {
1220 let (lo, hi) = self.gather_bounds_ev(e_ev);
1221 (e_ev - lo).max(hi - e_ev).max(0.0)
1222 }
1223
1224 /// The lowest and highest energies the kernel at `e_ev` gathers theory
1225 /// from, as `(low, high)` in eV with `low ≤ e_ev ≤ high`, over the points
1226 /// [`Self::broaden`] keeps and in its arithmetic. Returns `(e_ev, e_ev)`
1227 /// for non-positive or non-finite `e_ev`, an empty kernel set, or a
1228 /// non-positive flight path.
1229 #[must_use]
1230 pub fn gather_bounds_ev(&self, e_ev: f64) -> (f64, f64) {
1231 if e_ev <= 0.0 || !e_ev.is_finite() || self.kernels.is_empty() || self.flight_path_m <= 0.0
1232 {
1233 return (e_ev, e_ev);
1234 }
1235 let tof_center = TOF_FACTOR * self.flight_path_m / e_ev.sqrt();
1236 let (offsets, weights) = self.interpolated_kernel(e_ev);
1237 let (mut low, mut high) = (e_ev, e_ev);
1238 for (&dt, &w) in offsets.iter().zip(weights.iter()) {
1239 if w <= 0.0 {
1240 continue;
1241 }
1242 let tof_prime = tof_center - dt;
1243 if tof_prime <= 0.0 {
1244 continue;
1245 }
1246 let e_prime = (TOF_FACTOR * self.flight_path_m / tof_prime).powi(2);
1247 low = low.min(e_prime);
1248 high = high.max(e_prime);
1249 }
1250 (low, high)
1251 }
1252}
1253
1254/// Resolution function: analytical Gaussian, tabulated from Monte Carlo, or
1255/// analytical Ikeda–Carpenter moderator model.
1256///
1257/// The `Tabulated` and `IkedaCarpenter` variants wrap an `Arc` so that cloning
1258/// (e.g., per-pixel in spatial mapping) is a cheap reference-count bump rather
1259/// than a deep copy.
1260///
1261/// `IkedaCarpenter` synthesizes a [`TabulatedResolution`] at construction and
1262/// is applied through the *same* per-call convolution path as `Tabulated`
1263/// (`broaden` / `broaden_presorted` / `plan`) — only the kernel *source* differs
1264/// (analytic IC pulse vs Monte-Carlo file). This keeps the three-way resolution
1265/// cross-validation (Gaussian | tabulated-UDR | Ikeda–Carpenter) fair on the
1266/// reference broadening path. Note: `IkedaCarpenter` does **not** opt into the
1267/// spatial-map surrogate fast-paths (the scalar/cubature plans gate on
1268/// `Tabulated`); it falls back to the general path, which is correct but
1269/// unoptimized — see the resolution-calibration notes for the W6 follow-up.
1270#[derive(Debug, Clone)]
1271pub enum ResolutionFunction {
1272 /// Analytical Gaussian resolution from instrument parameters.
1273 Gaussian(ResolutionParams),
1274 /// Tabulated resolution from Monte Carlo instrument simulation.
1275 Tabulated(Arc<TabulatedResolution>),
1276 /// Analytical Ikeda–Carpenter moderator resolution model.
1277 IkedaCarpenter(Arc<crate::ikeda_carpenter::IkedaCarpenter>),
1278}
1279
1280/// Widths of Gaussian resolution the broadening limits reach on each side.
1281///
1282/// SAMMY Ref: `rsl/mrsl4.f90` `Wdsint`, `Wlow = Wup = Brdlim*Widgau`;
1283/// `inp/minp06.f` line 212, `Brdlim = 5`.
1284const BRDLIM: f64 = 5.0;
1285
1286/// Lowest energy the Gaussian working grid extends to, in eV: the PW-linear
1287/// quadrature maps points through `1/√E`, which has no value at zero.
1288const GAUSSIAN_LOW_ENERGY_FLOOR_EV: f64 = 0.001;
1289
1290impl ResolutionFunction {
1291 /// The energies a working grid for the data window `energies` has to span,
1292 /// as `(low, high)` in eV with `low ≤ e_min` and `high ≥ e_max`: SAMMY's
1293 /// Wdsint limits at the two ends for a Gaussian, the extremes of
1294 /// [`TabulatedResolution::gather_bounds_ev`] over the grid for a sampled
1295 /// kernel. Returns `(0.0, 0.0)` for an empty grid.
1296 #[must_use]
1297 pub fn grid_bounds_ev(&self, energies: &[f64]) -> (f64, f64) {
1298 let (Some(&e_min), Some(&e_max)) = (energies.first(), energies.last()) else {
1299 return (0.0, 0.0);
1300 };
1301 let sampled = |table: &TabulatedResolution| {
1302 energies.iter().fold((e_min, e_max), |(low, high), &e| {
1303 let (l, h) = table.gather_bounds_ev(e);
1304 (low.min(l), high.max(h))
1305 })
1306 };
1307 match self {
1308 Self::Gaussian(params) => {
1309 let wg_high = params.gaussian_width(e_max);
1310 let we_high = params.exp_width(e_max);
1311 // SAMMY grades the high side by the ratio of the Gaussian core to
1312 // the one-sided exponential tail (Wdsint, `Rwid`).
1313 let above = if we_high > 1e-30 {
1314 let rwid = wg_high / we_high;
1315 if rwid <= 1.0 {
1316 6.25 * we_high
1317 } else if rwid <= 2.0 {
1318 BRDLIM * (3.0 - rwid) * wg_high
1319 } else {
1320 BRDLIM * wg_high
1321 }
1322 } else {
1323 BRDLIM * wg_high
1324 };
1325 let below = (BRDLIM * params.gaussian_width(e_min))
1326 .min(e_min - GAUSSIAN_LOW_ENERGY_FLOOR_EV)
1327 .max(0.0);
1328 (e_min - below, e_max + above)
1329 }
1330 Self::Tabulated(tabulated) => sampled(tabulated),
1331 Self::IkedaCarpenter(ic) => sampled(ic.tabulated()),
1332 }
1333 }
1334
1335 /// Flight path used to map true neutron energy to detector time.
1336 pub fn flight_path_m(&self) -> f64 {
1337 match self {
1338 Self::Gaussian(params) => params.flight_path_m(),
1339 Self::Tabulated(tabulated) => tabulated.flight_path_m(),
1340 Self::IkedaCarpenter(ic) => ic.flight_path_m(),
1341 }
1342 }
1343
1344 /// The same resolution function read against a different flight path.
1345 ///
1346 /// A fit that frees `L_scale` evaluates the theory on an energy grid built
1347 /// with `L·L_scale`. The kernel has to be read against the same flight
1348 /// path or its width is wrong by that factor — on every family, since all
1349 /// three convert between energy and detector time through `L`. No variant
1350 /// resynthesizes: the flight path is not part of what the kernel IS, only
1351 /// of the map it is applied through.
1352 ///
1353 /// # Errors
1354 /// Returns [`ResolutionParseError::InvalidFormat`] if `flight_path_m` is
1355 /// not positive and finite.
1356 pub fn with_flight_path(&self, flight_path_m: f64) -> Result<Self, ResolutionParseError> {
1357 match self {
1358 Self::Gaussian(params) => Ok(Self::Gaussian(
1359 ResolutionParams::new(
1360 flight_path_m,
1361 params.delta_t_us(),
1362 params.delta_l_m(),
1363 params.delta_e_us(),
1364 )
1365 .map_err(|e| ResolutionParseError::InvalidFormat(e.to_string()))?,
1366 )),
1367 Self::Tabulated(tabulated) => Ok(Self::Tabulated(Arc::new(
1368 tabulated.with_flight_path(flight_path_m)?,
1369 ))),
1370 Self::IkedaCarpenter(ic) => Ok(Self::IkedaCarpenter(Arc::new(
1371 ic.with_flight_path(flight_path_m)?,
1372 ))),
1373 }
1374 }
1375
1376 /// Probability that one neutron of known true energy is recorded in each
1377 /// supplied detector-time bin.
1378 ///
1379 /// The tabulated and Ikeda–Carpenter variants are evaluated directly in
1380 /// detector time. In particular, the analytical IC variant does not pass
1381 /// through its legacy synthesized [`TabulatedResolution`] broadening
1382 /// table, and it refuses a Gaussian burst. The older Gaussian
1383 /// energy-broadening model has no physical detector-time probability law
1384 /// and is therefore rejected rather than silently treated as one.
1385 ///
1386 /// `timing_offset_us` is convention-dependent and NOT transferable
1387 /// between variants: a mode-centred tabulated (UDR) kernel places its
1388 /// pulse mode at the nominal arrival, so the offset must absorb the
1389 /// calibrated moderator mean delay, while the causal Ikeda–Carpenter
1390 /// pulse rises from the nominal arrival onward and its offset is a pure
1391 /// clock/detector shift. Swapping response models under one calibrated
1392 /// offset shifts every bin systematically.
1393 pub fn detector_bin_probabilities(
1394 &self,
1395 true_energy_ev: f64,
1396 detector_time_edges_us: &[f64],
1397 timing_offset_us: f64,
1398 ) -> Result<Vec<f64>, ResolutionParseError> {
1399 match self {
1400 Self::Tabulated(tabulated) => tabulated.detector_bin_probabilities(
1401 true_energy_ev,
1402 detector_time_edges_us,
1403 timing_offset_us,
1404 ),
1405 Self::IkedaCarpenter(ic) => ic.detector_bin_probabilities(
1406 true_energy_ev,
1407 detector_time_edges_us,
1408 timing_offset_us,
1409 ),
1410 Self::Gaussian(_) => Err(ResolutionParseError::InvalidFormat(
1411 "Gaussian energy broadening cannot produce detector-time bin probabilities; use a validated tabulated or Ikeda–Carpenter time response"
1412 .to_string(),
1413 )),
1414 }
1415 }
1416}
1417
1418/// Pre-built resolution-broadening plan for a specific target energy grid.
1419///
1420/// Encodes every quantity that depends only on the target grid, the
1421/// reference kernel, and the flight path — so applying the plan to a
1422/// spectrum reduces to a gather + multiply-add loop with no
1423/// transcendentals, no allocations, and no binary / pointer search.
1424///
1425/// Build via [`TabulatedResolution::plan`] — returns a `Result` and
1426/// validates the sorted-grid precondition that `broaden` enforces.
1427/// Apply via [`ResolutionPlan::apply`]. One plan is tied to one
1428/// `(target_energies, ref_energies, flight_path_m)` triple; the plan
1429/// owns a copy of the target-energy grid so callers cannot apply it to
1430/// a spectrum that was measured on a *different* grid even when the
1431/// grid length matches — use [`Self::target_energies`] to verify the
1432/// grid identity before applying.
1433///
1434/// The layout is a flat Struct-of-Arrays (SoA): per-target `(lo_idx,
1435/// frac, weight)` tuples packed into three parallel `Vec`s, with
1436/// `starts[i]..starts[i+1]` naming the range for target `i`. SoA keeps
1437/// the inner loop memory-access pattern sequential and cache-friendly.
1438#[derive(Debug, Clone)]
1439pub struct ResolutionPlan {
1440 /// Target energy grid the plan was built for (owned copy).
1441 ///
1442 /// Stored so `apply()` can verify `spectrum.len() == self.len()`
1443 /// and expose a cheap grid identity for caller-side caching.
1444 /// ~28 KB for the VENUS 3471-point grid — negligible compared to
1445 /// the ~8 MB `lo_idx`/`frac`/`weight` footprint of a full plan.
1446 target_energies: Vec<f64>,
1447 /// `starts[i]..starts[i+1]` indexes into `lo_idx`/`frac`/`weight`
1448 /// for target `i`. `starts` has length `target_energies.len() + 1`.
1449 starts: Vec<u32>,
1450 /// For each valid (target, kernel-point) entry: the lower bracket
1451 /// index into the target grid (spectrum[lo] + frac * (spectrum[lo+1]
1452 /// - spectrum[lo])).
1453 lo_idx: Vec<u32>,
1454 /// Spectrum-interp fraction in [0, 1]. Set to 0 for degenerate
1455 /// brackets; the apply-time loop short-circuits `frac == 0.0` so
1456 /// degenerate entries never touch `spectrum[lo+1]`. This matches
1457 /// `broaden_presorted` even when `spectrum[lo+1]` is NaN/±∞.
1458 frac: Vec<f64>,
1459 /// Pre-computed per-entry weight (`w * dt_width.abs()`). Summing
1460 /// these yields the per-target normalisation.
1461 weight: Vec<f64>,
1462 /// Pre-summed `Σ weight` per target (in the same accumulation order
1463 /// as `broaden_presorted` visits the valid entries). When `norm <=
1464 /// DIVISION_FLOOR` the apply path returns `spectrum[i]` directly
1465 /// — the exact `broaden_presorted` passthrough behaviour.
1466 norm: Vec<f64>,
1467}
1468
1469impl ResolutionPlan {
1470 /// Number of target energies this plan covers.
1471 pub fn len(&self) -> usize {
1472 self.target_energies.len()
1473 }
1474
1475 /// True when the plan covers no target energies.
1476 pub fn is_empty(&self) -> bool {
1477 self.target_energies.is_empty()
1478 }
1479
1480 /// Target energy grid the plan was built for.
1481 ///
1482 /// Callers implementing plan caches can compare this against their
1483 /// current grid to decide whether the plan is still valid. Using
1484 /// pointer identity of the returned slice gives an O(1) check when
1485 /// the grid hasn't moved; slice equality is `O(n)` but catches
1486 /// cases where the underlying buffer was reallocated.
1487 pub fn target_energies(&self) -> &[f64] {
1488 &self.target_energies
1489 }
1490
1491 /// Apply the plan to a spectrum on the same target grid the plan
1492 /// was built for.
1493 ///
1494 /// The spectrum length must equal [`Self::len`]. Passing a
1495 /// spectrum on a different grid that happens to have the same
1496 /// length is caller error — verify via [`Self::target_energies`]
1497 /// when in doubt.
1498 ///
1499 /// Bit-exact with `broaden_presorted(target_energies, spectrum)`
1500 /// for finite spectrum values; degenerate-bracket entries
1501 /// short-circuit the interpolation so the equivalence also holds
1502 /// when `spectrum[lo+1]` is NaN or ±∞ (the reference path returns
1503 /// `spectrum[lo]` directly in that case without touching the upper
1504 /// bracket).
1505 pub fn apply(&self, spectrum: &[f64]) -> Vec<f64> {
1506 let n = self.target_energies.len();
1507 assert_eq!(
1508 spectrum.len(),
1509 n,
1510 "spectrum length ({}) must match plan target-grid length ({})",
1511 spectrum.len(),
1512 n,
1513 );
1514 if n == 0 {
1515 return Vec::new();
1516 }
1517
1518 let mut result = vec![0.0f64; n];
1519
1520 // Pre-bind plan slices once per call and pre-slice each
1521 // target's entry range before the hot loop. This is a
1522 // bounds-check-elimination (BCE) refactor — every per-entry
1523 // index is proven in-bounds by the invariants established in
1524 // `plan_presorted`, so the inner loop uses `get_unchecked`
1525 // with SAFETY comments citing those invariants. The compiler
1526 // then auto-vectorizes the inner compute where profitable.
1527 //
1528 // We deliberately do NOT use explicit 2-wide SIMD here — an
1529 // experiment via the `wide` crate (commit abandoned;
1530 // `perf-lessons.md`) showed that 2-wide f64x2 with gather
1531 // emulation is net-negative on AArch64 Neon vs the compiler's
1532 // scalar auto-vectorization of the BCE'd inner loop. On
1533 // wider targets (x86 AVX2 / AVX-512) a SIMD rewrite could
1534 // still pay off but is out of scope here.
1535 //
1536 // Control flow, accumulation order, and the `frac == 0.0`
1537 // NaN-safety short-circuit are all preserved exactly so the
1538 // bit-exact contract with `broaden_presorted` holds for
1539 // finite AND pathological (NaN, ±∞) spectra.
1540 let lo_idx = self.lo_idx.as_slice();
1541 let frac_all = self.frac.as_slice();
1542 let weight_all = self.weight.as_slice();
1543 let starts = self.starts.as_slice();
1544 let norm = self.norm.as_slice();
1545 let spec = spectrum;
1546
1547 // Defence-in-depth: debug-only invariant checks right after
1548 // slice binding, so a future change to `plan_presorted` that
1549 // silently violates the `unsafe { get_unchecked }` SAFETY
1550 // claims below fails loudly in debug builds. Zero release-
1551 // build cost.
1552 debug_assert_eq!(starts.len(), n + 1);
1553 debug_assert_eq!(
1554 starts.last().copied(),
1555 Some(lo_idx.len() as u32),
1556 "plan_presorted invariant: starts.last() must equal lo_idx.len()",
1557 );
1558 debug_assert_eq!(lo_idx.len(), frac_all.len());
1559 debug_assert_eq!(lo_idx.len(), weight_all.len());
1560 debug_assert_eq!(norm.len(), n);
1561 debug_assert_eq!(spec.len(), n);
1562
1563 for i in 0..n {
1564 let norm_i = norm[i];
1565 if norm_i <= DIVISION_FLOOR {
1566 // Passthrough — matches `broaden_presorted`'s
1567 // `spectrum[i]` fallback for e ≤ 0, empty kernel, or
1568 // degenerate norm accumulation.
1569 result[i] = spec[i];
1570 continue;
1571 }
1572 let start = starts[i] as usize;
1573 let end = starts[i + 1] as usize;
1574 // Zip-compatible pre-bound slices of exactly `end - start`
1575 // elements each — the per-j bounds check is elided by the
1576 // compiler because the slice length bounds the loop.
1577 let los = &lo_idx[start..end];
1578 let fracs = &frac_all[start..end];
1579 let ws = &weight_all[start..end];
1580
1581 let mut sum = 0.0f64;
1582 for k in 0..los.len() {
1583 // SAFETY: `k < los.len()` is guaranteed by the range;
1584 // `los`, `fracs`, and `ws` all have length `end - start`
1585 // (same subslice bounds), so each `get_unchecked(k)`
1586 // read is in-bounds.
1587 let lo = unsafe { *los.get_unchecked(k) } as usize;
1588 let frac = unsafe { *fracs.get_unchecked(k) };
1589 let w = unsafe { *ws.get_unchecked(k) };
1590
1591 // Degenerate-bracket short-circuit: when the plan
1592 // built `frac = -0.0` (span < NEAR_ZERO_FLOOR) we skip
1593 // `spectrum[lo+1]` entirely. Without this branch,
1594 // `0.0 * NaN = NaN` would propagate and diverge from
1595 // the reference `broaden_presorted`, which returns
1596 // `spectrum[lo]` directly for that case. Branch is
1597 // well-predicted (degenerate brackets are rare on
1598 // real grids) and preserves bit-exactness under
1599 // pathological spectra.
1600 //
1601 // The check MUST use `to_bits()` because the non-
1602 // degenerate path can legitimately produce
1603 // `frac == +0.0` when `e_prime == energies[lo]`
1604 // exactly. In that case `broaden_presorted` still
1605 // reads `spectrum[lo+1]` (and propagates NaN if
1606 // present there), so the short-circuit MUST NOT
1607 // trigger. `+0.0 == -0.0` returns `true` but
1608 // `(+0.0).to_bits() != (-0.0).to_bits()`, so the
1609 // bit-pattern check disambiguates exactly which
1610 // semantic `plan_presorted` meant.
1611 let s = if frac.to_bits() == (-0.0_f64).to_bits() {
1612 // SAFETY: `lo < n` by plan invariant.
1613 // `plan_presorted` only pushes `lo = bracket_hi - 1`
1614 // with `bracket_hi ∈ [1, n - 1]`, so `lo ∈
1615 // [0, n - 2]`. `spec.len() == n` by the
1616 // precondition assert at the top of `apply`.
1617 unsafe { *spec.get_unchecked(lo) }
1618 } else {
1619 // SAFETY: same `lo ∈ [0, n - 2]` invariant, so
1620 // `lo + 1 ∈ [1, n - 1]` is also in-bounds.
1621 let s_lo = unsafe { *spec.get_unchecked(lo) };
1622 let s_hi = unsafe { *spec.get_unchecked(lo + 1) };
1623 s_lo + frac * (s_hi - s_lo)
1624 };
1625 // Serial accumulation preserved — no multi-accumulator
1626 // reassociation, no SIMD lane-wise tree reduce.
1627 // IEEE-754 addition is not associative; changing the
1628 // order would break bit-exactness with
1629 // `broaden_presorted_reference` (and all
1630 // `*_bit_exact_*` unit tests + the maintainers'
1631 // real-VENUS bit-exact baseline harness).
1632 sum += w * s;
1633 }
1634 result[i] = sum / norm_i;
1635 }
1636
1637 result
1638 }
1639
1640 /// Compile this plan into a row-stochastic CSR
1641 /// [`ResolutionMatrix`].
1642 ///
1643 /// The compiled matrix is an explicit sparse representation of
1644 /// the resolution operator `R` on the plan's target grid. Each
1645 /// row sums to 1.0 to machine precision (passthrough rows store
1646 /// a single `(i, i, 1.0)` entry to match [`ResolutionPlan::apply`]
1647 /// 's `norm ≤ DIVISION_FLOOR` fallback).
1648 ///
1649 /// Degenerate-bracket handling uses the `-0.0` sentinel
1650 /// convention from `plan_presorted`: if `plan.frac[e]` has the
1651 /// bit pattern of `-0.0`, the entry contributes `weight / norm`
1652 /// at column `lo` only (no `lo+1` bracket). A regular `+0.0`
1653 /// frac contributes `weight * 1.0 / norm` at `lo` and
1654 /// `weight * 0.0 / norm = 0.0` at `lo+1` — those zero columns
1655 /// are retained in CSR with `value = 0.0` to preserve
1656 /// downstream NaN-safety if the consumer re-multiplies by a
1657 /// spectrum containing NaN at `lo+1`.
1658 ///
1659 /// # Equivalence contract (finite spectra only)
1660 ///
1661 /// For a spectrum with **all finite values**, [`apply_r`] on the
1662 /// compiled matrix produces per-element output within `1e-12`
1663 /// relative tolerance of [`Self::apply`] on the same spectrum —
1664 /// not bit-exact, because the CSR matvec sums contributions in
1665 /// column order while `apply` sums in entry order and IEEE-754
1666 /// addition is non-associative. The `1e-12` bound accounts for
1667 /// accumulation error across the ~82 entries per row on the
1668 /// 3471-bin VENUS production grid (500 × 2.22e-16 ≈ 1.1e-13 per
1669 /// row; `1e-12` leaves comfortable headroom).
1670 ///
1671 /// # Non-finite and near-overflow spectra
1672 ///
1673 /// The equivalence bound does **NOT** extend to spectra with
1674 /// `NaN` / `±∞` values, **nor to near-f64::MAX overflow
1675 /// inputs**. Both divergences trace back to the same
1676 /// algebraic rewrite:
1677 ///
1678 /// * [`Self::apply`] computes each entry as `spec[lo] + frac *
1679 /// (spec[lo+1] - spec[lo])`, which can overflow the
1680 /// subtraction even for finite inputs (opposite-sign
1681 /// f64::MAX → `-∞`).
1682 /// * The compiled CSR form splits the interp into `(1 - frac) *
1683 /// spec[lo] + frac * spec[lo + 1]`, which scales before
1684 /// summing and stays finite in the same case.
1685 ///
1686 /// For bounded finite Beer-Lambert transmissions (`T ∈ [0, 1]`)
1687 /// neither divergence can arise; callers who deliberately pass
1688 /// non-finite or near-overflow spectra (e.g., as debug sentinels
1689 /// or out-of-range diagnostics) must not rely on cross-API
1690 /// equivalence. See `resolution_matrix_nonfinite_contract` and
1691 /// `resolution_matrix_large_finite_contract` for executable
1692 /// demonstrations.
1693 pub fn compile_to_matrix(&self) -> ResolutionMatrix {
1694 let n = self.target_energies.len();
1695 let mut row_starts: Vec<u32> = Vec::with_capacity(n + 1);
1696 row_starts.push(0);
1697 let mut col_indices: Vec<u32> = Vec::new();
1698 let mut values: Vec<f64> = Vec::new();
1699
1700 // Reusable per-row accumulator. Columns accumulate into a
1701 // BTreeMap keyed by spectrum index so the final CSR row is
1702 // emitted in ascending column order — the required CSR
1703 // invariant and the condition the `apply_r` equivalence
1704 // bound depends on.
1705 let mut acc: std::collections::BTreeMap<u32, f64> = std::collections::BTreeMap::new();
1706
1707 for i in 0..n {
1708 acc.clear();
1709 let norm_i = self.norm[i];
1710 if norm_i <= DIVISION_FLOOR {
1711 // Passthrough row — matches `apply`'s early return.
1712 col_indices.push(i as u32);
1713 values.push(1.0);
1714 // See u32-overflow `debug_assert!` below — the same
1715 // bound applies after every `push`.
1716 debug_assert!(
1717 col_indices.len() <= u32::MAX as usize,
1718 "CSR row_starts/col_indices u32 overflow: nnz = {}",
1719 col_indices.len(),
1720 );
1721 row_starts.push(col_indices.len() as u32);
1722 continue;
1723 }
1724 let start = self.starts[i] as usize;
1725 let end = self.starts[i + 1] as usize;
1726 for e in start..end {
1727 let lo = self.lo_idx[e];
1728 let frac = self.frac[e];
1729 let w = self.weight[e];
1730 if frac.to_bits() == (-0.0_f64).to_bits() {
1731 // Degenerate bracket — `apply` reads `spec[lo]`
1732 // only, so the CSR row contributes only at `lo`.
1733 *acc.entry(lo).or_insert(0.0) += w / norm_i;
1734 } else {
1735 // Regular linear-interp entry: `w * ((1 - frac)
1736 // * spec[lo] + frac * spec[lo + 1]) / norm_i`.
1737 *acc.entry(lo).or_insert(0.0) += w * (1.0 - frac) / norm_i;
1738 *acc.entry(lo + 1).or_insert(0.0) += w * frac / norm_i;
1739 }
1740 }
1741 for (&col, &val) in acc.iter() {
1742 col_indices.push(col);
1743 values.push(val);
1744 }
1745 // Defence-in-depth: a future large-grid caller that
1746 // accumulates more than u32::MAX entries would silently
1747 // truncate the `as u32` cast below. The `plan_presorted`
1748 // helper already has matching `debug_assert!` guards on
1749 // its u32 offsets (resolution.rs, `plan_presorted`).
1750 debug_assert!(
1751 col_indices.len() <= u32::MAX as usize,
1752 "CSR row_starts/col_indices u32 overflow: nnz = {}",
1753 col_indices.len(),
1754 );
1755 row_starts.push(col_indices.len() as u32);
1756 }
1757
1758 ResolutionMatrix {
1759 target_energies: self.target_energies.clone(),
1760 row_starts,
1761 col_indices,
1762 values,
1763 }
1764 }
1765}
1766
1767/// Row-stochastic CSR representation of the resolution operator `R`
1768/// on a fixed target energy grid.
1769///
1770/// Built from a [`ResolutionPlan`] via
1771/// [`ResolutionPlan::compile_to_matrix`]. Exposed so downstream
1772/// surrogates (see ) can access the row-local entries
1773/// `R_{i, j}` directly for LP / quadrature construction.
1774///
1775/// Owns a copy of the target energy grid for the same reason
1776/// [`ResolutionPlan`] does: caller-side grid-identity checks and
1777/// explicit grid-mismatch errors via
1778/// [`ResolutionError::MatrixGridMismatch`].
1779#[derive(Debug, Clone)]
1780pub struct ResolutionMatrix {
1781 /// Target energy grid the matrix was compiled for (owned copy).
1782 target_energies: Vec<f64>,
1783 /// `row_starts[i]..row_starts[i+1]` indexes into
1784 /// `col_indices`/`values` for row `i`. Length `n + 1`.
1785 row_starts: Vec<u32>,
1786 /// Column indices in ascending order within each row.
1787 col_indices: Vec<u32>,
1788 /// CSR values. Row `i` sums to 1.0 within machine precision
1789 /// (passthrough rows store exactly `1.0` at column `i`).
1790 values: Vec<f64>,
1791}
1792
1793impl ResolutionMatrix {
1794 /// Number of rows (target-grid size) covered by this matrix.
1795 pub fn len(&self) -> usize {
1796 self.target_energies.len()
1797 }
1798
1799 /// True when the matrix covers no target energies.
1800 pub fn is_empty(&self) -> bool {
1801 self.target_energies.is_empty()
1802 }
1803
1804 /// Total number of stored entries (structural nnz).
1805 ///
1806 /// Regular-bracket entries with `frac == +0.0` retain a
1807 /// zero-valued contribution at the `lo + 1` column to preserve
1808 /// NaN-safety under re-application to spectra with NaN at that
1809 /// column; those stored zeros are counted in this total.
1810 pub fn nnz(&self) -> usize {
1811 self.values.len()
1812 }
1813
1814 /// Target energy grid the matrix was compiled for.
1815 pub fn target_energies(&self) -> &[f64] {
1816 &self.target_energies
1817 }
1818
1819 /// CSR row-start offsets. `row_starts()[i]..row_starts()[i+1]`
1820 /// names the entry range for row `i`. Length `len() + 1`.
1821 pub fn row_starts(&self) -> &[u32] {
1822 &self.row_starts
1823 }
1824
1825 /// CSR column indices. Sorted ascending within each row.
1826 pub fn col_indices(&self) -> &[u32] {
1827 &self.col_indices
1828 }
1829
1830 /// CSR values. Each row sums to 1.0 to machine precision.
1831 pub fn values(&self) -> &[f64] {
1832 &self.values
1833 }
1834}
1835
1836/// Apply a compiled [`ResolutionMatrix`] to a spectrum on the same
1837/// target grid the matrix was compiled for.
1838///
1839/// For finite spectra, the output is numerically equivalent to
1840/// [`ResolutionPlan::apply`] on the same spectrum within `1e-12`
1841/// relative tolerance per element; not bit-exact, because CSR matvec
1842/// sums in column order while `ResolutionPlan::apply` sums in entry
1843/// order.
1844///
1845/// # Non-finite and near-overflow inputs
1846///
1847/// See [`ResolutionPlan::compile_to_matrix`] for the full contract
1848/// on `NaN` / `±∞` spectra **and on near-f64::MAX finite spectra** —
1849/// the equivalence bound does not extend to either. Production
1850/// forward models feed Beer-Lambert transmissions (`T ∈ [0, 1]`) so
1851/// the distinction never arises in practice.
1852///
1853/// # Panics
1854///
1855/// Panics if `spectrum.len() != matrix.len()`. Use
1856/// [`apply_resolution_with_matrix`] for a checked entrypoint that
1857/// returns [`ResolutionError::LengthMismatch`] instead.
1858pub fn apply_r(matrix: &ResolutionMatrix, spectrum: &[f64]) -> Vec<f64> {
1859 let n = matrix.len();
1860 assert_eq!(
1861 spectrum.len(),
1862 n,
1863 "spectrum length ({}) must match matrix grid length ({})",
1864 spectrum.len(),
1865 n,
1866 );
1867 let mut out = vec![0.0f64; n];
1868 for (i, out_i) in out.iter_mut().enumerate() {
1869 let start = matrix.row_starts[i] as usize;
1870 let end = matrix.row_starts[i + 1] as usize;
1871 let mut sum = 0.0f64;
1872 for e in start..end {
1873 let col = matrix.col_indices[e] as usize;
1874 sum += matrix.values[e] * spectrum[col];
1875 }
1876 *out_i = sum;
1877 }
1878 out
1879}
1880
1881/// Checked variant of [`apply_r`] that validates the matrix was
1882/// compiled for `energies` before applying.
1883///
1884/// Returns [`ResolutionError::LengthMismatch`] when either
1885/// `energies` or `spectrum` has a length that disagrees with the
1886/// matrix grid size. For the `spectrum` check, the `energies` field
1887/// of the returned error holds the matrix grid length (the required
1888/// length) so callers can read it as "expected vs got". Returns
1889/// [`ResolutionError::MatrixGridMismatch`] when the lengths match
1890/// but the grid contents differ (per-element `to_bits()` compare).
1891///
1892/// Unlike [`apply_resolution_with_plan`], this entrypoint does not
1893/// enforce an ascending `energies` grid through the crate's internal
1894/// `validate_inputs` helper. That check is redundant here: the plan
1895/// that produced the matrix was itself built on a sorted grid (via
1896/// [`TabulatedResolution::plan`], which validates sortedness), and the
1897/// stored `target_energies` copy is used in the `to_bits()`
1898/// grid-identity check above. Any `energies` slice that is not
1899/// bit-identical to the matrix's stored copy — including an unsorted
1900/// permutation of the same values — fails with
1901/// [`ResolutionError::MatrixGridMismatch`].
1902pub fn apply_resolution_with_matrix(
1903 energies: &[f64],
1904 matrix: &ResolutionMatrix,
1905 spectrum: &[f64],
1906) -> Result<Vec<f64>, ResolutionError> {
1907 if energies.len() != matrix.len() {
1908 return Err(ResolutionError::LengthMismatch {
1909 energies: energies.len(),
1910 data: matrix.len(),
1911 });
1912 }
1913 if spectrum.len() != matrix.len() {
1914 // Reuse the `LengthMismatch` variant for the spectrum branch:
1915 // `energies` = expected length (matrix grid size), `data` =
1916 // actual spectrum length. See docstring above.
1917 return Err(ResolutionError::LengthMismatch {
1918 energies: matrix.len(),
1919 data: spectrum.len(),
1920 });
1921 }
1922 for (i, (e_cur, e_ref)) in energies.iter().zip(matrix.target_energies()).enumerate() {
1923 // `to_bits()` equality catches `-0.0 vs +0.0` and NaN-bit
1924 // differences that float `==` silently accepts or rejects.
1925 if e_cur.to_bits() != e_ref.to_bits() {
1926 return Err(ResolutionError::MatrixGridMismatch {
1927 first_diff_index: i,
1928 });
1929 }
1930 }
1931 Ok(apply_r(matrix, spectrum))
1932}
1933
1934impl TabulatedResolution {
1935 /// Parse a VENUS/FTS resolution file.
1936 ///
1937 /// # Arguments
1938 /// * `text` — File contents as a string.
1939 /// * `flight_path_m` — Flight path length in meters.
1940 pub fn from_text(text: &str, flight_path_m: f64) -> Result<Self, ResolutionParseError> {
1941 if !flight_path_m.is_finite() || flight_path_m <= 0.0 {
1942 return Err(ResolutionParseError::InvalidFormat(format!(
1943 "Flight path must be positive and finite, got {flight_path_m}"
1944 )));
1945 }
1946 let mut lines = text.lines();
1947
1948 // Skip header and separator
1949 let _header = lines
1950 .next()
1951 .ok_or(ResolutionParseError::InvalidFormat("Empty file".into()))?;
1952 let _sep = lines.next().ok_or(ResolutionParseError::InvalidFormat(
1953 "Missing separator".into(),
1954 ))?;
1955
1956 let mut ref_energies = Vec::new();
1957 let mut kernels = Vec::new();
1958 let mut current_energy: Option<f64> = None;
1959 let mut current_offsets: Vec<f64> = Vec::new();
1960 let mut current_weights: Vec<f64> = Vec::new();
1961
1962 for line in lines {
1963 let trimmed = line.trim();
1964 if trimmed.is_empty() {
1965 // End of current block
1966 if let Some(e) = current_energy.take() {
1967 ref_energies.push(e);
1968 kernels.push((
1969 std::mem::take(&mut current_offsets),
1970 std::mem::take(&mut current_weights),
1971 ));
1972 }
1973 continue;
1974 }
1975
1976 let parts: Vec<&str> = trimmed.split_whitespace().collect();
1977 if parts.len() != 2 {
1978 if current_energy.is_some() {
1979 return Err(ResolutionParseError::InvalidFormat(format!(
1980 "Expected 2 columns inside energy block, got {}: '{}'",
1981 parts.len(),
1982 trimmed
1983 )));
1984 }
1985 // Outside a data block (e.g. extra header lines) — skip
1986 continue;
1987 }
1988
1989 let x: f64 = parts[0].parse().map_err(|_| {
1990 ResolutionParseError::InvalidFormat(format!("Cannot parse float: '{}'", parts[0]))
1991 })?;
1992 let y: f64 = parts[1].parse().map_err(|_| {
1993 ResolutionParseError::InvalidFormat(format!("Cannot parse float: '{}'", parts[1]))
1994 })?;
1995
1996 if current_energy.is_none() {
1997 // First line of block: energy + 0.0 marker
1998 current_energy = Some(x);
1999 } else {
2000 current_offsets.push(x);
2001 current_weights.push(y);
2002 }
2003 }
2004
2005 // Flush last block
2006 if let Some(e) = current_energy.take() {
2007 ref_energies.push(e);
2008 kernels.push((current_offsets, current_weights));
2009 }
2010
2011 if ref_energies.is_empty() {
2012 return Err(ResolutionParseError::InvalidFormat(
2013 "No energy blocks found".into(),
2014 ));
2015 }
2016
2017 // Validate finite, POSITIVE, strictly ascending reference
2018 // energies. The finiteness check must come first: NaN compares
2019 // false against everything, so a NaN energy would slip through
2020 // the ascending check below and then poison the bracketing
2021 // binary search. Positivity is load-bearing twice over: the
2022 // TOF map t = TOF_FACTOR·L/√E needs E > 0, and the
2023 // between-reference width interpolation takes ln(E_ref) — a
2024 // non-positive reference would turn every blended weight into
2025 // NaN, which bypasses the broadener's norm guard and silently
2026 // disables broadening (NaN comparisons are false).
2027 if let Some(bad) = ref_energies.iter().find(|e| !(e.is_finite() && **e > 0.0)) {
2028 return Err(ResolutionParseError::InvalidFormat(format!(
2029 "Reference energies must be finite and positive, got {bad}"
2030 )));
2031 }
2032 for i in 1..ref_energies.len() {
2033 if ref_energies[i] <= ref_energies[i - 1] {
2034 return Err(ResolutionParseError::InvalidFormat(format!(
2035 "Reference energies must be strictly ascending, but E[{}]={} <= E[{}]={}",
2036 i,
2037 ref_energies[i],
2038 i - 1,
2039 ref_energies[i - 1],
2040 )));
2041 }
2042 }
2043
2044 // Per-block kernel invariants — the same set `from_kernels`
2045 // enforces, because both constructors feed the same broadener:
2046 //
2047 // * non-empty: an empty block would make `broaden_presorted`
2048 // accumulate `norm == 0` and silently pass the spectrum
2049 // through;
2050 // * all-finite offsets and weights (checked BEFORE sortedness:
2051 // a NaN offset fails the ascending comparison too, and the
2052 // "must be strictly ascending" message would mislead): a NaN
2053 // weight poisons the kernel norm and bypasses the division
2054 // guard;
2055 // * strictly ascending offsets: the monotonic two-pointer
2056 // bracket walk and the trapezoidal `dt` widths
2057 // (`offsets[k+1] − offsets[k−1]`) both assume sorted offsets
2058 // — an unsorted block would broaden silently-wrong.
2059 for (i, (offsets, weights)) in kernels.iter().enumerate() {
2060 if offsets.is_empty() {
2061 return Err(ResolutionParseError::InvalidFormat(format!(
2062 "Kernel {i} (E = {} eV) has no (offset, weight) points",
2063 ref_energies[i],
2064 )));
2065 }
2066 if offsets.iter().any(|v| !v.is_finite()) || weights.iter().any(|v| !v.is_finite()) {
2067 return Err(ResolutionParseError::InvalidFormat(format!(
2068 "Kernel {i} (E = {} eV) contains non-finite offsets or weights",
2069 ref_energies[i],
2070 )));
2071 }
2072 // Negative weights have no physical meaning (a resolution
2073 // kernel is an emission-time density); the broadener skips
2074 // w <= 0 entries, and the width machinery's trapezoidal
2075 // moments must integrate over the same domain — reject at
2076 // the door rather than let the two disagree.
2077 if weights.iter().any(|&v| v < 0.0) {
2078 return Err(ResolutionParseError::InvalidFormat(format!(
2079 "Kernel {i} (E = {} eV) contains negative weights",
2080 ref_energies[i],
2081 )));
2082 }
2083 if !offsets.windows(2).all(|w| w[0] < w[1]) {
2084 return Err(ResolutionParseError::InvalidFormat(format!(
2085 "Kernel {i} (E = {} eV) TOF offsets must be strictly ascending",
2086 ref_energies[i],
2087 )));
2088 }
2089 }
2090
2091 Ok(TabulatedResolution {
2092 ref_energies: Arc::new(ref_energies),
2093 kernels: Arc::new(kernels),
2094 flight_path_m,
2095 })
2096 }
2097
2098 /// Build a tabulated resolution directly from synthesized kernels.
2099 ///
2100 /// Used by the analytical [`crate::ikeda_carpenter::IkedaCarpenter`] model,
2101 /// which generates `(tof_offset_µs, weight)` kernels at a set of reference
2102 /// energies and then rides the exact same broadening machinery as a
2103 /// Monte-Carlo file. Validates the same invariants `from_text` enforces:
2104 /// non-empty + strictly ascending reference energies, one kernel per energy,
2105 /// each kernel non-empty with matching offset/weight lengths, and all-finite
2106 /// offsets/weights.
2107 ///
2108 /// # Errors
2109 /// Returns [`ResolutionParseError::InvalidFormat`] if the flight path is
2110 /// not positive and finite, if the reference energies are empty / not
2111 /// strictly ascending, if the energy and kernel counts differ, if any
2112 /// kernel is empty, if a kernel's offset and weight vectors differ in
2113 /// length, or if any offset/weight is non-finite.
2114 pub fn from_kernels(
2115 ref_energies: Vec<f64>,
2116 kernels: Vec<(Vec<f64>, Vec<f64>)>,
2117 flight_path_m: f64,
2118 ) -> Result<Self, ResolutionParseError> {
2119 if !flight_path_m.is_finite() || flight_path_m <= 0.0 {
2120 return Err(ResolutionParseError::InvalidFormat(format!(
2121 "Flight path must be positive and finite, got {flight_path_m}"
2122 )));
2123 }
2124 if ref_energies.is_empty() {
2125 return Err(ResolutionParseError::InvalidFormat(
2126 "No reference energies provided".into(),
2127 ));
2128 }
2129 if ref_energies.len() != kernels.len() {
2130 return Err(ResolutionParseError::InvalidFormat(format!(
2131 "Reference-energy count {} != kernel count {}",
2132 ref_energies.len(),
2133 kernels.len(),
2134 )));
2135 }
2136 // Finiteness first: NaN compares false against everything, so a
2137 // NaN energy would slip through the ascending check below and
2138 // then poison the bracketing binary search. Positivity is
2139 // load-bearing twice over: the TOF map needs E > 0, and the
2140 // between-reference width interpolation takes ln(E_ref) — a
2141 // non-positive reference would turn every blended weight into
2142 // NaN and silently disable broadening.
2143 if let Some(bad) = ref_energies.iter().find(|e| !(e.is_finite() && **e > 0.0)) {
2144 return Err(ResolutionParseError::InvalidFormat(format!(
2145 "Reference energies must be finite and positive, got {bad}"
2146 )));
2147 }
2148 for i in 1..ref_energies.len() {
2149 if ref_energies[i] <= ref_energies[i - 1] {
2150 return Err(ResolutionParseError::InvalidFormat(format!(
2151 "Reference energies must be strictly ascending, but E[{}]={} <= E[{}]={}",
2152 i,
2153 ref_energies[i],
2154 i - 1,
2155 ref_energies[i - 1],
2156 )));
2157 }
2158 }
2159 for (i, (offsets, weights)) in kernels.iter().enumerate() {
2160 // Reject empty kernels: an empty `(offsets, weights)` passes the
2161 // length-match check (0 == 0) but later makes `broaden_presorted`
2162 // accumulate `norm == 0` and silently fall back to pass-through.
2163 if offsets.is_empty() {
2164 return Err(ResolutionParseError::InvalidFormat(format!(
2165 "Kernel {i} is empty; each kernel needs at least one (offset, weight) point"
2166 )));
2167 }
2168 if offsets.len() != weights.len() {
2169 return Err(ResolutionParseError::InvalidFormat(format!(
2170 "Kernel {i} offset length {} != weight length {}",
2171 offsets.len(),
2172 weights.len(),
2173 )));
2174 }
2175 // Reject non-finite synthesized kernels (e.g. a poisoned analytic
2176 // pulse) so a NaN table fails loudly here rather than silently
2177 // degrading the convolution to pass-through (a NaN norm bypasses the
2178 // division guard in `broaden_presorted`).
2179 if offsets.iter().chain(weights.iter()).any(|v| !v.is_finite()) {
2180 return Err(ResolutionParseError::InvalidFormat(format!(
2181 "Kernel {i} contains non-finite offset/weight values"
2182 )));
2183 }
2184 // Negative weights have no physical meaning; the broadener
2185 // skips w <= 0 entries and the trapezoidal width moments
2186 // must integrate over the same domain (see `from_text`).
2187 if weights.iter().any(|&v| v < 0.0) {
2188 return Err(ResolutionParseError::InvalidFormat(format!(
2189 "Kernel {i} contains negative weights"
2190 )));
2191 }
2192 // Offsets must be strictly ascending: `broaden_presorted`/`plan` walk a
2193 // monotonic two-pointer bracket and derive trapezoidal `dt` widths from
2194 // `offsets[k+1] − offsets[k−1]`, both of which assume sorted offsets.
2195 // An unsorted kernel would otherwise broaden silently-wrong, not error.
2196 if !offsets.windows(2).all(|w| w[0] < w[1]) {
2197 return Err(ResolutionParseError::InvalidFormat(format!(
2198 "Kernel {i} TOF offsets must be strictly ascending"
2199 )));
2200 }
2201 }
2202 Ok(TabulatedResolution {
2203 ref_energies: Arc::new(ref_energies),
2204 kernels: Arc::new(kernels),
2205 flight_path_m,
2206 })
2207 }
2208
2209 /// Parse a VENUS/FTS resolution file from disk.
2210 pub fn from_file(path: &str, flight_path_m: f64) -> Result<Self, ResolutionParseError> {
2211 let text = std::fs::read_to_string(path)
2212 .map_err(|e| ResolutionParseError::IoError(format!("Cannot read '{}': {}", path, e)))?;
2213 Self::from_text(&text, flight_path_m)
2214 }
2215
2216 /// Apply tabulated resolution broadening to a spectrum.
2217 ///
2218 /// For each energy point:
2219 /// 1. Find bracketing reference energies and interpolate kernel (log-space)
2220 /// 2. Convert TOF offsets to energy offsets using exact TOF↔energy relation
2221 /// 3. Convolve spectrum with interpolated kernel (trapezoidal integration)
2222 ///
2223 /// Kernel points whose delayed-emission offset reaches the nominal
2224 /// flight time at the target energy (`dt ≥ TOF(E)`) gather from
2225 /// past infinite energy; they are dropped and the kernel
2226 /// renormalized over the surviving points, mirroring the grid-edge
2227 /// handling — see the tail-truncation note on `broaden_presorted`.
2228 ///
2229 /// # Errors
2230 /// Returns [`ResolutionError::LengthMismatch`] if the arrays differ in
2231 /// length, or [`ResolutionError::UnsortedEnergies`] if the energy grid is
2232 /// not sorted in non-descending order.
2233 pub fn broaden(&self, energies: &[f64], spectrum: &[f64]) -> Result<Vec<f64>, ResolutionError> {
2234 validate_inputs(energies, spectrum)?;
2235 Ok(self.broaden_presorted(energies, spectrum))
2236 }
2237
2238 /// Tabulated resolution broadening assuming the energy grid is already
2239 /// validated (sorted ascending, same length as spectrum).
2240 ///
2241 /// ## Convolution orientation
2242 ///
2243 /// The broadened value at measured TOF `t` gathers theory at
2244 /// `t − dt`: a neutron *measured* at `t` whose emission was delayed
2245 /// by `dt` really flew for `t − dt`, i.e. it is faster than nominal,
2246 /// so the kernel's positive-offset (delayed-emission) tail pulls
2247 /// theory from earlier TOF = **higher** energy, and a resonance dip
2248 /// acquires its tail toward lower apparent energy. This is the
2249 /// convolution ∫R(τ)·S(t−τ)dτ, matching SAMMY's user-defined
2250 /// resolution: `sammy/src/udr/mudr4.f90` `Ud_Convolute` (line 288)
2251 /// computes `Cc(Tc) = ∫Bb(τ)·Aa(Tc−τ)dτ` (the theory-segment search
2252 /// binds `Tc−Ta` to the kernel grid), and `Ud_Mesh_Time` (line 7)
2253 /// sizes the needed theory window as `[T0−UdT_last, T0−UdT_first]`.
2254 ///
2255 /// ## Delayed-tail truncation at short nominal flight times
2256 ///
2257 /// A kernel point with `dt ≥ tof_center` would gather at
2258 /// `tof_prime = tof_center − dt ≤ 0`, i.e. from past infinite
2259 /// energy where no theory value exists. Such points are silently
2260 /// dropped and the trapezoidal normalisation runs over the
2261 /// surviving points — the same truncate-and-renormalize treatment
2262 /// applied when `e_prime` falls outside the target grid
2263 /// (`[e_min, e_max]`). This engages when the nominal TOF at the
2264 /// target energy is shorter than the kernel's delayed-emission
2265 /// reach — very high energy and/or a short flight path — and is
2266 /// reachable at *any* target energy because `interpolated_kernel`
2267 /// clamps to the nearest reference kernel outside the tabulated
2268 /// range.
2269 ///
2270 /// ## Inner-loop optimization
2271 ///
2272 /// The per-kernel-point spectrum interpolation uses a **two-pointer
2273 /// walk** instead of a binary search: `e_prime` is monotonically
2274 /// increasing in `k` (since `dt = offsets[k]` is non-decreasing,
2275 /// `TOF' = tof_center − dt` is non-increasing, and `E' = (L/TOF')²`
2276 /// is non-decreasing). We maintain `bracket_hi` as the smallest
2277 /// index into `energies[]` whose value is `>= e_prime`; the walk is
2278 /// a bidirectional fixed-point search (first kernel point descends
2279 /// from `n−1`, subsequent points walk upward). Amortized O(1) per
2280 /// kernel point within a target.
2281 ///
2282 /// Math is identical to the reference implementation pinned by
2283 /// `broaden_presorted_reference` in the test module.
2284 ///
2285 /// For callers that broaden many spectra on the same target grid —
2286 /// LM iterations with fixed TZERO, spatial maps with a pre-calibrated
2287 /// energy axis — [`TabulatedResolution::plan`] +
2288 /// [`ResolutionPlan::apply`] produce bit-exact output while
2289 /// hoisting the per-target invariants (TOF conversion, kernel
2290 /// interpolation, bracket lookup, trapezoidal widths) out of the
2291 /// broadening hot loop. This `broaden_presorted` entry is the
2292 /// single-broadening path and keeps the original inline
2293 /// implementation to avoid plan-construction overhead on one-shot
2294 /// callers.
2295 pub(crate) fn broaden_presorted(&self, energies: &[f64], spectrum: &[f64]) -> Vec<f64> {
2296 let n = energies.len();
2297 if n == 0 {
2298 return vec![];
2299 }
2300 if n == 1 {
2301 return spectrum.to_vec();
2302 }
2303
2304 let e_min = energies[0];
2305 let e_max = energies[n - 1];
2306
2307 let mut result = vec![0.0f64; n];
2308
2309 for i in 0..n {
2310 let e = energies[i];
2311 if e <= 0.0 {
2312 result[i] = spectrum[i];
2313 continue;
2314 }
2315
2316 let tof_center = TOF_FACTOR * self.flight_path_m / e.sqrt();
2317 let (offsets, weights) = self.interpolated_kernel(e);
2318 let n_k = offsets.len();
2319 let mut bracket_hi: usize = n - 1;
2320
2321 let mut sum = 0.0;
2322 let mut norm = 0.0;
2323
2324 for k in 0..n_k {
2325 let dt = offsets[k];
2326 let w = weights[k];
2327 if w <= 0.0 {
2328 continue;
2329 }
2330
2331 // Convolution gather: theory at t − dt (see docstring;
2332 // SAMMY mudr4.f90 Ud_Convolute).
2333 let tof_prime = tof_center - dt;
2334 if tof_prime <= 0.0 {
2335 continue;
2336 }
2337
2338 let e_prime = (TOF_FACTOR * self.flight_path_m / tof_prime).powi(2);
2339
2340 if e_prime < e_min || e_prime > e_max {
2341 continue;
2342 }
2343
2344 while bracket_hi > 1 && energies[bracket_hi - 1] > e_prime {
2345 bracket_hi -= 1;
2346 }
2347 while bracket_hi < n - 1 && energies[bracket_hi] <= e_prime {
2348 bracket_hi += 1;
2349 }
2350
2351 let lo = bracket_hi - 1;
2352 let hi = bracket_hi;
2353 let span = energies[hi] - energies[lo];
2354 let s = if span.abs() < NEAR_ZERO_FLOOR {
2355 spectrum[lo]
2356 } else {
2357 let frac = (e_prime - energies[lo]) / span;
2358 spectrum[lo] + frac * (spectrum[hi] - spectrum[lo])
2359 };
2360
2361 let dt_width = if k > 0 && k < n_k - 1 {
2362 (offsets[k + 1] - offsets[k - 1]) * 0.5
2363 } else if k == 0 && n_k > 1 {
2364 offsets[1] - offsets[0]
2365 } else if k == n_k - 1 && n_k > 1 {
2366 offsets[k] - offsets[k - 1]
2367 } else {
2368 1.0
2369 };
2370
2371 let weight = w * dt_width.abs();
2372 sum += weight * s;
2373 norm += weight;
2374 }
2375
2376 result[i] = if norm > DIVISION_FLOOR {
2377 sum / norm
2378 } else {
2379 spectrum[i]
2380 };
2381 }
2382
2383 result
2384 }
2385
2386 /// Build a reusable broadening plan for a specific target energy grid.
2387 ///
2388 /// Validates that `energies` is non-descending — the same sorted-grid
2389 /// precondition enforced by [`TabulatedResolution::broaden`] via
2390 /// `validate_inputs`. An
2391 /// unsorted grid would produce a silently-wrong plan (misbracketed
2392 /// `e_prime` lookups against `e_min` / `e_max`), so it must be
2393 /// caught at build time rather than returning garbage from
2394 /// [`ResolutionPlan::apply`].
2395 ///
2396 /// The plan hoists every quantity that depends only on
2397 /// `(target_energies, self.ref_energies, self.flight_path_m)` —
2398 /// namely the TOF conversion, the log-space kernel interpolation,
2399 /// the per-kernel-point `e_prime` and spectrum-bracket lookup, and
2400 /// the trapezoidal integration widths. Applying the plan to a
2401 /// spectrum becomes a pure gather + multiply-add loop.
2402 ///
2403 /// Build cost: same as one call to the private `broaden_presorted`
2404 /// helper (O(N_target × N_kernel) TOF / bracket / interp work, plus
2405 /// ~2 × N_kernel log-interp ops per target energy for
2406 /// `interpolated_kernel`). Apply cost per target: 1 branch +
2407 /// ~3 loads + 3 flops per retained entry, plus the final divide —
2408 /// typically < 10 % of the build cost. The payoff comes from
2409 /// reusing one plan across many spectra.
2410 ///
2411 /// Bit-exact with `broaden_presorted`: pre-computes the same
2412 /// floating-point sequences (TOF, `e_prime`, `dt_width`, `frac`,
2413 /// `weight`, `norm`) in the same order.
2414 ///
2415 /// # Errors
2416 /// Returns [`ResolutionError::UnsortedEnergies`] if `energies` is
2417 /// not non-descending.
2418 pub fn plan(&self, energies: &[f64]) -> Result<ResolutionPlan, ResolutionError> {
2419 if !energies.windows(2).all(|w| w[0] <= w[1]) {
2420 return Err(ResolutionError::UnsortedEnergies);
2421 }
2422 Ok(self.plan_presorted(energies))
2423 }
2424
2425 /// Build a plan assuming `energies` is already validated as
2426 /// non-descending. Used internally by `broaden_presorted` (whose
2427 /// caller already validated the grid) and by `plan()` after its
2428 /// validation succeeded.
2429 fn plan_presorted(&self, energies: &[f64]) -> ResolutionPlan {
2430 let n = energies.len();
2431 if n == 0 {
2432 return ResolutionPlan {
2433 target_energies: Vec::new(),
2434 starts: vec![0],
2435 lo_idx: Vec::new(),
2436 frac: Vec::new(),
2437 weight: Vec::new(),
2438 norm: Vec::new(),
2439 };
2440 }
2441 if n == 1 {
2442 // No bracket available; passthrough. Represent as n=1 with
2443 // zero entries and norm=0, which triggers the passthrough
2444 // branch in `ResolutionPlan::apply`.
2445 return ResolutionPlan {
2446 target_energies: energies.to_vec(),
2447 starts: vec![0, 0],
2448 lo_idx: Vec::new(),
2449 frac: Vec::new(),
2450 weight: Vec::new(),
2451 norm: vec![0.0],
2452 };
2453 }
2454
2455 let e_min = energies[0];
2456 let e_max = energies[n - 1];
2457
2458 // Preallocate the entry Vecs to ~n × 2·kernel_len: the
2459 // width-normalized shape blend merges the two bracketing
2460 // blocks, so between-reference targets emit up to
2461 // n_lo + n_hi points (~2× a single block; real VENUS grids
2462 // push ~n × 998 entries). Over-allocating for at-reference
2463 // targets is cheap vs. repeated grow-and-memcpy during the
2464 // plan build.
2465 let estimated_kernel_len = self.kernels.first().map_or(0, |(off, _)| off.len());
2466 let estimated_entries = n.saturating_mul(estimated_kernel_len.saturating_mul(2));
2467
2468 let mut starts: Vec<u32> = Vec::with_capacity(n + 1);
2469 let mut lo_idx: Vec<u32> = Vec::with_capacity(estimated_entries);
2470 let mut frac: Vec<f64> = Vec::with_capacity(estimated_entries);
2471 let mut weight: Vec<f64> = Vec::with_capacity(estimated_entries);
2472 let mut norm: Vec<f64> = Vec::with_capacity(n);
2473
2474 starts.push(0);
2475
2476 for i in 0..n {
2477 let e = energies[i];
2478 if e <= 0.0 {
2479 // Passthrough: no entries contribute, norm=0.
2480 norm.push(0.0);
2481 // Guard the u32 invariant for diagnostic callers; the
2482 // headroom is enormous for any realistic grid (VENUS
2483 // 3471 × 499 ≈ 1.7M entries, u32::MAX ≈ 4.29B), but
2484 // the debug-only assert documents the contract.
2485 debug_assert!(
2486 lo_idx.len() <= u32::MAX as usize,
2487 "plan entry count overflows u32"
2488 );
2489 starts.push(lo_idx.len() as u32);
2490 continue;
2491 }
2492
2493 // TOF at this energy: t = TOF_FACTOR * L / sqrt(E).
2494 // Computed here in the plan build and NOT at apply time — this
2495 // is the main invariant we hoist.
2496 let tof_center = TOF_FACTOR * self.flight_path_m / e.sqrt();
2497
2498 // Interpolated kernel at this target energy. Allocates two
2499 // ~N_kernel Vecs; those allocations happen once per plan
2500 // build instead of once per broadening call.
2501 let (offsets, weights) = self.interpolated_kernel(e);
2502 let n_k = offsets.len();
2503
2504 // Two-pointer walk state (same invariant as broaden_presorted).
2505 let mut bracket_hi: usize = n - 1;
2506
2507 let mut target_norm = 0.0;
2508
2509 for k in 0..n_k {
2510 let dt = offsets[k];
2511 let w = weights[k];
2512 if w <= 0.0 {
2513 continue;
2514 }
2515
2516 // Convolution gather: theory at t − dt (see
2517 // broaden_presorted; SAMMY mudr4.f90 Ud_Convolute).
2518 // Points with dt ≥ tof_center gather from past infinite
2519 // energy and are dropped, renormalizing over the
2520 // survivors (see the tail-truncation note there).
2521 let tof_prime = tof_center - dt;
2522 if tof_prime <= 0.0 {
2523 continue;
2524 }
2525
2526 let e_prime = (TOF_FACTOR * self.flight_path_m / tof_prime).powi(2);
2527
2528 if e_prime < e_min || e_prime > e_max {
2529 continue;
2530 }
2531
2532 // Two-pointer walk — same logic + invariants as
2533 // broaden_presorted, in the same order, so bracket_hi
2534 // reaches the identical position for each kept (i, k).
2535 while bracket_hi > 1 && energies[bracket_hi - 1] > e_prime {
2536 bracket_hi -= 1;
2537 }
2538 while bracket_hi < n - 1 && energies[bracket_hi] <= e_prime {
2539 bracket_hi += 1;
2540 }
2541
2542 let lo = bracket_hi - 1;
2543 let hi = bracket_hi;
2544 let span = energies[hi] - energies[lo];
2545 // Degenerate-bracket guard: if span < NEAR_ZERO_FLOOR,
2546 // broaden_presorted returns `spectrum[lo]` directly
2547 // without the interp arithmetic. Store `frac = -0.0`
2548 // — the apply path short-circuits on the exact bit
2549 // pattern of `-0.0` and returns `spectrum[lo]` without
2550 // touching `spectrum[lo+1]`, so bit-exactness holds
2551 // even if `spectrum[lo+1]` is NaN or ±∞.
2552 //
2553 // `-0.0` (negative-signed zero) is used as the sentinel
2554 // because the non-degenerate path can legitimately
2555 // produce `frac == +0.0` when `e_prime == energies[lo]`
2556 // exactly — in that case `broaden_presorted` still
2557 // reads `spectrum[lo+1]` (and propagates NaN if present
2558 // there), so the apply path MUST do the same. `+0.0`
2559 // and `-0.0` compare equal under `==` but differ in
2560 // `to_bits()`, which is what apply uses to disambiguate.
2561 let entry_frac = if span.abs() < NEAR_ZERO_FLOOR {
2562 -0.0_f64
2563 } else {
2564 (e_prime - energies[lo]) / span
2565 };
2566
2567 let dt_width = if k > 0 && k < n_k - 1 {
2568 (offsets[k + 1] - offsets[k - 1]) * 0.5
2569 } else if k == 0 && n_k > 1 {
2570 offsets[1] - offsets[0]
2571 } else if k == n_k - 1 && n_k > 1 {
2572 offsets[k] - offsets[k - 1]
2573 } else {
2574 1.0
2575 };
2576
2577 let entry_weight = w * dt_width.abs();
2578
2579 debug_assert!(
2580 lo_idx.len() < u32::MAX as usize,
2581 "plan entry count overflows u32"
2582 );
2583 lo_idx.push(lo as u32);
2584 frac.push(entry_frac);
2585 weight.push(entry_weight);
2586 target_norm += entry_weight;
2587 }
2588
2589 norm.push(target_norm);
2590 starts.push(lo_idx.len() as u32);
2591 }
2592
2593 ResolutionPlan {
2594 target_energies: energies.to_vec(),
2595 starts,
2596 lo_idx,
2597 frac,
2598 weight,
2599 norm,
2600 }
2601 }
2602
2603 /// Interpolate the kernel at an arbitrary energy as a
2604 /// **width-normalized shape blend** between the two bracketing
2605 /// reference kernels:
2606 ///
2607 /// 1. Exact hits (a reference energy, or outside the reference
2608 /// range) return that reference kernel unchanged.
2609 /// 2. Each bracketing block's trapezoidal RMS width `σ_b` is
2610 /// computed ([`trapezoidal_moments`]); the target width is the
2611 /// **geometric** interpolation `σ_t = σ_lo·(σ_hi/σ_lo)^frac`
2612 /// with `frac` linear in log E — exact for the physical
2613 /// power-law width `σ_t ∝ E^p` (log σ linear in log E).
2614 /// 3. Both blocks' offsets are scaled about the mode (offset 0 —
2615 /// the anchoring convention; see the discussion in
2616 /// `ikeda_carpenter`) by `σ_t/σ_b`, merged into one
2617 /// strictly-ascending grid, and the weights blended pointwise:
2618 /// `w = w_lo(x) + frac·(w_hi(x) − w_lo(x))`, each block's weight
2619 /// linearly interpolated (zero outside its support). Identical
2620 /// blocks reduce to a **bitwise identity** (the scale ratios are
2621 /// exactly 1.0 and the blend form is exact when `w_lo == w_hi`).
2622 ///
2623 /// Degenerate blocks (single-point, zero mass → `σ_b ≤ 0`) fall
2624 /// back to the nearer reference clone.
2625 ///
2626 /// ## INTENTIONAL DEPARTURE from SAMMY
2627 ///
2628 /// SAMMY's user-defined resolution blends both the amplitude and
2629 /// the time-point arrays element-wise, **linear in E**: in the
2630 /// active `Gen_Udr_Par` (sammy/src/udr/mudr3.f90, subroutine at
2631 /// line 164; blend block at lines 241–255: `UdR_E(J,Nud) =
2632 /// UdR(J,I−1,Nud)·a + UdR(J,I,Nud)·b` and likewise `UdT_E`), i.e.
2633 /// the arithmetic width chord. (The file's first routine
2634 /// `Gen_Udr_Par_x` holds the same blend at lines 92–112 but is
2635 /// marked "never called" at line 10 — cite the live twin.)
2636 /// Because the physical width law
2637 /// `σ_t ∝ ~E^{−1/2}` is convex, that chord systematically
2638 /// over-widens every between-reference energy: +7.8 % at the
2639 /// midpoint of synthetic 10/50 eV Gaussian blocks, +4.1…+7.2 %
2640 /// across the production VENUS 5→50 eV reference gap — a direct
2641 /// resolution-width systematic that biases fitted temperatures
2642 /// low. The geometric-width shape blend above removes it (and the
2643 /// nearest-reference width sawtooth that unequal point counts used
2644 /// to produce). SAMMY additionally re-aligns the blended kernel so
2645 /// its trapezoidal centroid `Ct` sits at T = 0 ("Realign so that
2646 /// centroid is at T=0", mudr3.f90 lines 266–292);
2647 /// NEREIDS does not re-align: the blend scales offsets about 0, so each
2648 /// table keeps its own origin — a loaded UDR file its peak, a synthesized
2649 /// Ikeda–Carpenter table the emission instant.
2650 ///
2651 /// Exactness caveat: the blend reproduces `σ_t` exactly when the
2652 /// two blocks' width-normalized shapes agree (self-similar
2653 /// families, e.g. any pure power-law file). Genuinely different
2654 /// bracketing shapes add a second-order mixture-spread term —
2655 /// inherent to shape blending and far below the removed chord
2656 /// error for real moderator files.
2657 ///
2658 /// Allocates the two output Vecs per call (≈ n_lo + n_hi points
2659 /// between references); scratch reuse is tracked separately as a
2660 /// performance follow-up.
2661 ///
2662 /// `ref_energies` is validated as strictly ascending by `from_text()` /
2663 /// `from_file()` at construction time, so no per-call sort check is needed.
2664 fn interpolated_kernel(&self, energy: f64) -> (Vec<f64>, Vec<f64>) {
2665 debug_assert!(
2666 self.ref_energies.windows(2).all(|w| w[0] < w[1]),
2667 "ref_energies must be strictly ascending (invariant broken)"
2668 );
2669 let n_ref = self.ref_energies.len();
2670
2671 // NaN target energies never reach here through the validated
2672 // public paths (a NaN in a multi-point grid fails the sorted
2673 // check), but every comparison below is false for NaN, and the
2674 // width-scaled merge would then emit NaN offsets — violating
2675 // the strictly-ascending, all-finite invariants the broadener
2676 // assumes. Clamp to the lowest reference so the output kernel
2677 // is well-formed unconditionally (the old element-wise blend
2678 // degraded to its nearest-reference fallback here by accident
2679 // of its monotonicity guard; this keeps that graceful
2680 // behaviour explicit). +∞ needs no guard: the high clamp
2681 // below already catches it.
2682 if energy.is_nan() {
2683 return self.kernels[0].clone();
2684 }
2685
2686 // Clamp to nearest reference if outside range
2687 if energy <= self.ref_energies[0] || n_ref == 1 {
2688 return self.kernels[0].clone();
2689 }
2690 if energy >= self.ref_energies[n_ref - 1] {
2691 return self.kernels[n_ref - 1].clone();
2692 }
2693
2694 // Find bracketing indices
2695 let pos = self.ref_energies.partition_point(|&e| e < energy);
2696 // Interior exact hit: return that reference unchanged rather than
2697 // blending it with itself, which reproduces it only up to ULPs.
2698 if self.ref_energies[pos] == energy {
2699 return self.kernels[pos].clone();
2700 }
2701 let idx = if pos == 0 {
2702 0
2703 } else {
2704 (pos - 1).min(n_ref - 2)
2705 };
2706
2707 let e_lo = self.ref_energies[idx];
2708 let e_hi = self.ref_energies[idx + 1];
2709
2710 // Log-space interpolation fraction
2711 let frac = (energy.ln() - e_lo.ln()) / (e_hi.ln() - e_lo.ln());
2712
2713 let (off_lo, w_lo) = &self.kernels[idx];
2714 let (off_hi, w_hi) = &self.kernels[idx + 1];
2715
2716 let nearest = || -> (Vec<f64>, Vec<f64>) {
2717 let k = if frac < 0.5 {
2718 &self.kernels[idx]
2719 } else {
2720 &self.kernels[idx + 1]
2721 };
2722 (k.0.clone(), k.1.clone())
2723 };
2724
2725 let (_, s_lo) = trapezoidal_moments(off_lo, w_lo);
2726 let (_, s_hi) = trapezoidal_moments(off_hi, w_hi);
2727 // Degenerate blocks (σ ≤ 0) cannot be width-scaled, and a
2728 // non-finite fraction (constructors enforce positive reference
2729 // energies, so defense-in-depth only) would poison every
2730 // blended weight with NaN — both take the nearest-clone
2731 // fallback so the output is well-formed unconditionally.
2732 if !(s_lo.is_finite() && s_lo > 0.0 && s_hi.is_finite() && s_hi > 0.0 && frac.is_finite()) {
2733 return nearest();
2734 }
2735
2736 // Geometric width interpolation. This float form (ratio +
2737 // powf) is a bitwise no-op when σ_lo == σ_hi: the ratio is
2738 // exactly 1.0, powf(1.0, f) == 1.0, so both scale factors are
2739 // exactly 1.0 and scaled offsets are the originals.
2740 let s_t = s_lo * (s_hi / s_lo).powf(frac);
2741 let r_lo = s_t / s_lo;
2742 let r_hi = s_t / s_hi;
2743
2744 // Single-pass sorted merge of the two scaled grids. Each
2745 // block's weight at a merged point is its own tabulated value
2746 // when the point came from that block, else the linear
2747 // interpolation of its shape (zero outside its support).
2748 let n_lo = off_lo.len();
2749 let n_hi = off_hi.len();
2750 let mut out_off: Vec<f64> = Vec::with_capacity(n_lo + n_hi);
2751 let mut out_w: Vec<f64> = Vec::with_capacity(n_lo + n_hi);
2752
2753 // Piecewise-linear sample of one block's shape at `x`, with a
2754 // monotone cursor (merged points arrive in ascending order).
2755 let sample = |offs: &[f64], ws: &[f64], r: f64, cursor: &mut usize, x: f64| -> f64 {
2756 let n = offs.len();
2757 if x < offs[0] * r || x > offs[n - 1] * r {
2758 return 0.0;
2759 }
2760 while *cursor + 1 < n && offs[*cursor + 1] * r <= x {
2761 *cursor += 1;
2762 }
2763 if *cursor + 1 >= n {
2764 return ws[n - 1];
2765 }
2766 let x0 = offs[*cursor] * r;
2767 let x1 = offs[*cursor + 1] * r;
2768 let span = x1 - x0;
2769 if x <= x0 || span <= 0.0 {
2770 return ws[*cursor];
2771 }
2772 ws[*cursor] + (x - x0) / span * (ws[*cursor + 1] - ws[*cursor])
2773 };
2774
2775 let (mut i, mut j) = (0usize, 0usize);
2776 let (mut ci, mut cj) = (0usize, 0usize);
2777 while i < n_lo || j < n_hi {
2778 let xa = if i < n_lo {
2779 off_lo[i] * r_lo
2780 } else {
2781 f64::INFINITY
2782 };
2783 let xb = if j < n_hi {
2784 off_hi[j] * r_hi
2785 } else {
2786 f64::INFINITY
2787 };
2788 // Near-duplicate merge (covers the exact 0 == 0 mode point):
2789 // emit one point carrying both blocks' exact tabulated
2790 // weights, so identical blocks blend to their exact values.
2791 let near_dup = i < n_lo
2792 && j < n_hi
2793 && (xa - xb).abs() <= 4.0 * f64::EPSILON * xa.abs().max(xb.abs());
2794 let (x, wl, wh) = if near_dup {
2795 let v = (xa, w_lo[i], w_hi[j]);
2796 i += 1;
2797 j += 1;
2798 v
2799 } else if xa < xb {
2800 let v = (xa, w_lo[i], sample(off_hi, w_hi, r_hi, &mut cj, xa));
2801 i += 1;
2802 v
2803 } else {
2804 let v = (xb, sample(off_lo, w_lo, r_lo, &mut ci, xb), w_hi[j]);
2805 j += 1;
2806 v
2807 };
2808 // Defensive strict-ascension guard: the broadener's
2809 // trapezoidal quadrature and two-pointer walk require it
2810 // unconditionally, regardless of the dedup epsilon.
2811 if let Some(&last) = out_off.last()
2812 && x <= last
2813 {
2814 continue;
2815 }
2816 out_off.push(x);
2817 // Exact when `wl == wh` (identical blocks), and exactly the
2818 // endpoint values at frac → 0/1.
2819 out_w.push(wl + frac * (wh - wl));
2820 }
2821
2822 // Blended weights inherit the blocks' scale (peak-normalized by
2823 // convention); the broadener renormalizes at apply time, so no
2824 // re-normalization is done here — preserving the bitwise
2825 // identity for identical blocks unconditionally.
2826 (out_off, out_w)
2827 }
2828}
2829
2830/// Apply resolution broadening using either Gaussian or tabulated kernel.
2831///
2832/// # Errors
2833/// Returns [`ResolutionError`] if the energy grid is unsorted or array
2834/// lengths do not match.
2835pub fn apply_resolution(
2836 energies: &[f64],
2837 spectrum: &[f64],
2838 resolution: &ResolutionFunction,
2839) -> Result<Vec<f64>, ResolutionError> {
2840 match resolution {
2841 ResolutionFunction::Gaussian(params) => resolution_broaden(energies, spectrum, params),
2842 ResolutionFunction::Tabulated(tab) => tab.broaden(energies, spectrum),
2843 ResolutionFunction::IkedaCarpenter(ic) => ic.tabulated().broaden(energies, spectrum),
2844 }
2845}
2846
2847/// Apply resolution broadening assuming the energy grid is already validated
2848/// (sorted ascending, same length as spectrum).
2849///
2850/// Used by `transmission.rs` to avoid redundant O(N) sort checks when
2851/// broadening multiple isotopes on the same pre-validated energy grid.
2852pub(crate) fn apply_resolution_presorted(
2853 energies: &[f64],
2854 spectrum: &[f64],
2855 resolution: &ResolutionFunction,
2856) -> Vec<f64> {
2857 match resolution {
2858 ResolutionFunction::Gaussian(params) => {
2859 resolution_broaden_presorted(energies, spectrum, params)
2860 }
2861 ResolutionFunction::Tabulated(tab) => tab.broaden_presorted(energies, spectrum),
2862 ResolutionFunction::IkedaCarpenter(ic) => {
2863 ic.tabulated().broaden_presorted(energies, spectrum)
2864 }
2865 }
2866}
2867
2868/// Build a broadening plan for `(energies, resolution)`.
2869///
2870/// Returns `Some(plan)` for [`ResolutionFunction::Tabulated`] and
2871/// [`ResolutionFunction::IkedaCarpenter`] (which rides its synthesized tabulated
2872/// kernel) — the plan hoists the per-target TOF / kernel-interpolation / bracket
2873/// / trap-weight work that would otherwise run on every call to
2874/// [`apply_resolution`]. Returns `None` for
2875/// [`ResolutionFunction::Gaussian`] — the Gaussian path has no
2876/// meaningful pixel-invariant kernel structure to cache at this
2877/// level, so callers fall back to the per-call broadening path with
2878/// no loss.
2879///
2880/// Callers that want a single-branch API can unconditionally call
2881/// [`apply_resolution_with_plan`] passing `plan.as_ref()`; when the
2882/// plan is `None` it transparently forwards to the non-plan path and
2883/// returns byte-identical output.
2884///
2885/// # Errors
2886/// Returns [`ResolutionError::UnsortedEnergies`] if `energies` is not
2887/// non-descending — the same precondition that [`apply_resolution`]
2888/// enforces per-call.
2889pub fn build_resolution_plan(
2890 energies: &[f64],
2891 resolution: &ResolutionFunction,
2892) -> Result<Option<ResolutionPlan>, ResolutionError> {
2893 match resolution {
2894 ResolutionFunction::Gaussian(_) => {
2895 if !energies.windows(2).all(|w| w[0] <= w[1]) {
2896 return Err(ResolutionError::UnsortedEnergies);
2897 }
2898 Ok(None)
2899 }
2900 ResolutionFunction::Tabulated(tab) => tab.plan(energies).map(Some),
2901 ResolutionFunction::IkedaCarpenter(ic) => ic.tabulated().plan(energies).map(Some),
2902 }
2903}
2904
2905/// Apply resolution broadening, optionally via a pre-built
2906/// [`ResolutionPlan`].
2907///
2908/// When `plan` is `Some(p)` and `resolution` is a tabulated kernel,
2909/// `p.apply(spectrum)` runs the cached per-target broadening inner
2910/// loop — the expensive TOF / kernel-interpolation / bracket work
2911/// was already captured at plan build time.
2912///
2913/// When `plan` is `None`, or when `resolution` is Gaussian, the call
2914/// forwards to [`apply_resolution`] and is byte-identical to the
2915/// un-planned path.
2916///
2917/// # Errors
2918/// * Returns the same errors as [`apply_resolution`] on the non-plan
2919/// path.
2920/// * Returns [`ResolutionError::LengthMismatch`] if the plan was built
2921/// for a different-length grid than `energies`, or if
2922/// `energies.len() != spectrum.len()`.
2923/// * Returns [`ResolutionError::PlanGridMismatch`] if the plan was
2924/// built for a different grid of the same length — the cached
2925/// `(lo_idx, frac, weight)` entries encode brackets into the old
2926/// grid and would silently produce a wrong broadened spectrum if
2927/// applied.
2928pub fn apply_resolution_with_plan(
2929 plan: Option<&ResolutionPlan>,
2930 energies: &[f64],
2931 spectrum: &[f64],
2932 resolution: &ResolutionFunction,
2933) -> Result<Vec<f64>, ResolutionError> {
2934 if let Some(p) = plan
2935 && matches!(
2936 resolution,
2937 ResolutionFunction::Tabulated(_) | ResolutionFunction::IkedaCarpenter(_)
2938 )
2939 {
2940 validate_inputs(energies, spectrum)?;
2941 if p.len() != energies.len() {
2942 return Err(ResolutionError::LengthMismatch {
2943 energies: energies.len(),
2944 data: p.len(),
2945 });
2946 }
2947 // Grid-identity check. A plan built for a different grid of
2948 // the same length would still pass the length check and then
2949 // gather spectrum values at brackets that belong to the old
2950 // grid — silently corrupt output. Pointer identity is not
2951 // enough here because callers legitimately hold the plan and
2952 // the target grid in separate `Arc`s whose storage may or
2953 // may not alias; bit-exact content equality is the only
2954 // robust invariant. The cost is one full grid scan per
2955 // broadening call (O(n), ~27 KB of f64 values for the VENUS
2956 // 3471-point grid) — orders of magnitude cheaper than the
2957 // broadening itself and cheap vs the silent-staleness
2958 // failure mode.
2959 let plan_grid = p.target_energies();
2960 for i in 0..plan_grid.len() {
2961 if plan_grid[i].to_bits() != energies[i].to_bits() {
2962 return Err(ResolutionError::PlanGridMismatch {
2963 first_diff_index: i,
2964 });
2965 }
2966 }
2967 return Ok(p.apply(spectrum));
2968 }
2969 apply_resolution(energies, spectrum, resolution)
2970}
2971
2972/// Errors from resolution file parsing.
2973#[derive(Debug)]
2974pub enum ResolutionParseError {
2975 InvalidFormat(String),
2976 IoError(String),
2977}
2978
2979impl fmt::Display for ResolutionParseError {
2980 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2981 match self {
2982 Self::InvalidFormat(msg) => write!(f, "Invalid resolution file format: {}", msg),
2983 Self::IoError(msg) => write!(f, "I/O error: {}", msg),
2984 }
2985 }
2986}
2987
2988impl std::error::Error for ResolutionParseError {}
2989
2990/// Test-only helpers for building synthetic [`ResolutionPlan`] /
2991/// [`TabulatedResolution`] instances without going through the full
2992/// parse-from-text path. Gated behind `#[cfg(test)]` for in-crate
2993/// tests and the `test-support` feature flag for downstream-crate
2994/// tests. Never ships in a release build with `test-support =
2995/// false` (the default), so the raw constructors remain out of the
2996/// production API surface.
2997#[cfg(any(test, feature = "test-support"))]
2998pub mod test_support {
2999 use super::{DIVISION_FLOOR, NEAR_ZERO_FLOOR, ResolutionPlan, TabulatedResolution};
3000 use std::sync::Arc;
3001
3002 /// Build a [`ResolutionPlan`] directly from its SoA fields.
3003 ///
3004 /// The caller is responsible for maintaining the invariants that
3005 /// `plan_presorted` normally enforces (`starts.last() ==
3006 /// lo_idx.len()`, lo_idx in [0, n-2] for regular entries, etc.).
3007 /// Used by surrogate-module tests + downstream crate tests to
3008 /// construct hand-designed plans that exercise specific CSR
3009 /// patterns.
3010 pub fn plan_from_raw_parts(
3011 target_energies: Vec<f64>,
3012 starts: Vec<u32>,
3013 lo_idx: Vec<u32>,
3014 frac: Vec<f64>,
3015 weight: Vec<f64>,
3016 norm: Vec<f64>,
3017 ) -> ResolutionPlan {
3018 ResolutionPlan {
3019 target_energies,
3020 starts,
3021 lo_idx,
3022 frac,
3023 weight,
3024 norm,
3025 }
3026 }
3027
3028 /// Build a minimal [`TabulatedResolution`] with a single
3029 /// reference energy and a trivial delta-like kernel — just
3030 /// enough for tests that need an `InstrumentParams` with a
3031 /// tabulated resolution (e.g., to exercise cubature dispatch
3032 /// guards that refuse Gaussian resolution). The broadening
3033 /// would be effectively identity if anyone ever called it, but
3034 /// typical consumers (cubature dispatch tests) never invoke the
3035 /// kernel.
3036 pub fn trivial_tabulated_resolution(flight_path_m: f64) -> TabulatedResolution {
3037 TabulatedResolution {
3038 ref_energies: Arc::new(vec![100.0]),
3039 kernels: Arc::new(vec![(vec![-1e-6, 0.0, 1e-6], vec![0.0, 1.0, 0.0])]),
3040 flight_path_m,
3041 }
3042 }
3043
3044 /// Thin shim exposing the crate-internal
3045 /// [`TabulatedResolution::broaden_presorted`] to integration
3046 /// tests living under `crates/nereids-physics/tests/`. The
3047 /// internal method stays `pub(crate)` so the broader public
3048 /// API surface (the operator-style `apply_resolution`,
3049 /// `plan` / `apply` / `compile_to_matrix`) remains the
3050 /// recommended entry point; this shim exists solely so the
3051 /// fixture-gated bit-exact regression and microbenchmark
3052 /// tests can call the optimized two-pointer walk directly.
3053 pub fn broaden_presorted(
3054 tab: &TabulatedResolution,
3055 energies: &[f64],
3056 spectrum: &[f64],
3057 ) -> Vec<f64> {
3058 tab.broaden_presorted(energies, spectrum)
3059 }
3060
3061 /// Thin shim exposing the crate-internal
3062 /// `TabulatedResolution::interpolated_kernel` to integration
3063 /// tests. Needed by the bit-exact equivalence oracle that
3064 /// the fixture-gated regression test runs against the
3065 /// optimized `broaden_presorted` path.
3066 pub fn interpolated_kernel(tab: &TabulatedResolution, energy: f64) -> (Vec<f64>, Vec<f64>) {
3067 tab.interpolated_kernel(energy)
3068 }
3069
3070 /// The TOF↔energy conversion factor used by
3071 /// `broaden_presorted` and its oracle. Exposed so the
3072 /// integration-test oracle uses the exact same constant as
3073 /// the SUT, preserving bit-exact equivalence.
3074 pub const TOF_FACTOR: f64 = super::TOF_FACTOR;
3075
3076 /// Bit-exact regression oracle: binary-search piecewise-linear
3077 /// interpolation of `spectrum` onto target energy `e`. Mirror of
3078 /// the pre-optimization in-src reference; called transitively by
3079 /// [`broaden_presorted_reference`] inside its inner convolution
3080 /// loop.
3081 ///
3082 /// **Do not "clean up" this function.** Consumers are bit-exact
3083 /// equivalence tests that pin the optimized two-pointer path in
3084 /// `TabulatedResolution::broaden_presorted` against this byte-
3085 /// identical reference. A rewrite that shifts edge cases by even
3086 /// one bit (e.g. swapping the upper-bound binary search for
3087 /// `partition_point`, or changing the `<=` to `<` in the midpoint
3088 /// comparison) would flip the comparison and invalidate the
3089 /// regression suite.
3090 pub fn interp_spectrum(energies: &[f64], spectrum: &[f64], e: f64) -> Option<f64> {
3091 let n = energies.len();
3092 if n == 0 {
3093 return None;
3094 }
3095 if e < energies[0] || e > energies[n - 1] {
3096 return None;
3097 }
3098 let mut lo = 0;
3099 let mut hi = n - 1;
3100 while hi - lo > 1 {
3101 let mid = (lo + hi) / 2;
3102 if energies[mid] <= e {
3103 lo = mid;
3104 } else {
3105 hi = mid;
3106 }
3107 }
3108 let span = energies[hi] - energies[lo];
3109 if span.abs() < NEAR_ZERO_FLOOR {
3110 return Some(spectrum[lo]);
3111 }
3112 let frac = (e - energies[lo]) / span;
3113 Some(spectrum[lo] + frac * (spectrum[hi] - spectrum[lo]))
3114 }
3115
3116 /// Bit-exact regression oracle: pre-optimization reference
3117 /// implementation of [`TabulatedResolution::broaden_presorted`].
3118 /// Used by the in-src + integration + microbench bit-exact test
3119 /// suites that pin the optimized two-pointer path against this
3120 /// reference.
3121 ///
3122 /// Same "do not refactor" caveat as [`interp_spectrum`]. Reads
3123 /// `tab.flight_path_m()` via the public getter so the oracle stays
3124 /// callable from integration tests (the underlying field is
3125 /// private; the getter is a no-op wrapper, so byte-equivalence
3126 /// against the original in-src field access is preserved).
3127 pub fn broaden_presorted_reference(
3128 tab: &TabulatedResolution,
3129 energies: &[f64],
3130 spectrum: &[f64],
3131 ) -> Vec<f64> {
3132 let n = energies.len();
3133 if n == 0 {
3134 return vec![];
3135 }
3136
3137 let mut result = vec![0.0f64; n];
3138
3139 for i in 0..n {
3140 let e = energies[i];
3141 if e <= 0.0 {
3142 result[i] = spectrum[i];
3143 continue;
3144 }
3145
3146 let tof_center = TOF_FACTOR * tab.flight_path_m() / e.sqrt();
3147 let (offsets, weights) = tab.interpolated_kernel(e);
3148
3149 let mut sum = 0.0;
3150 let mut norm = 0.0;
3151
3152 for k in 0..offsets.len() {
3153 let dt = offsets[k];
3154 let w = weights[k];
3155 if w <= 0.0 {
3156 continue;
3157 }
3158
3159 // Convolution gather: theory at t − dt (see
3160 // broaden_presorted; SAMMY mudr4.f90 Ud_Convolute).
3161 // Points with dt ≥ tof_center gather from past infinite
3162 // energy and are dropped, renormalizing over the
3163 // survivors (see the tail-truncation note there).
3164 let tof_prime = tof_center - dt;
3165 if tof_prime <= 0.0 {
3166 continue;
3167 }
3168
3169 let e_prime = (TOF_FACTOR * tab.flight_path_m() / tof_prime).powi(2);
3170
3171 let s = match interp_spectrum(energies, spectrum, e_prime) {
3172 Some(v) => v,
3173 None => continue,
3174 };
3175
3176 let dt_width = if k > 0 && k < offsets.len() - 1 {
3177 (offsets[k + 1] - offsets[k - 1]) * 0.5
3178 } else if k == 0 && offsets.len() > 1 {
3179 offsets[1] - offsets[0]
3180 } else if k == offsets.len() - 1 && offsets.len() > 1 {
3181 offsets[k] - offsets[k - 1]
3182 } else {
3183 1.0
3184 };
3185
3186 let weight = w * dt_width.abs();
3187 sum += weight * s;
3188 norm += weight;
3189 }
3190
3191 result[i] = if norm > DIVISION_FLOOR {
3192 sum / norm
3193 } else {
3194 spectrum[i]
3195 };
3196 }
3197
3198 result
3199 }
3200}
3201
3202#[cfg(test)]
3203mod tests {
3204 use super::*;
3205 use nereids_core::constants;
3206
3207 fn kernel_centroid_std(offs: &[f64], wts: &[f64]) -> (f64, f64) {
3208 let wsum: f64 = wts.iter().sum();
3209 let c = offs.iter().zip(wts).map(|(o, w)| o * w).sum::<f64>() / wsum;
3210 let var = offs
3211 .iter()
3212 .zip(wts)
3213 .map(|(o, w)| w * (o - c).powi(2))
3214 .sum::<f64>()
3215 / wsum;
3216 (c, var.sqrt())
3217 }
3218
3219 const SQRT_PI: f64 = 1.772_453_850_905_516;
3220
3221 /// Largest relative difference from the independent erfc of `libm`.
3222 fn worst(points: impl Iterator<Item = (f64, f64, f64)>) -> (f64, f64) {
3223 points
3224 .map(|(x, ours, want)| (x, (ours - want).abs() / want.abs()))
3225 .fold((f64::NAN, 0.0), |w, p| if p.1 > w.1 { p } else { w })
3226 }
3227
3228 #[test]
3229 fn exerfc_and_erfc_match_an_independent_erfc_on_both_sides_of_zero() {
3230 let xs = (-2600..=2600).map(|i| f64::from(i) * 0.01);
3231 let (x, relative) = worst(xs.map(|x| {
3232 let want = SQRT_PI * (x * x).exp() * libm::erfc(x);
3233 (x, exerfc(x), want)
3234 }));
3235 assert!(relative < 2e-7, "exerfc({x}) is {relative:.2e} off");
3236
3237 let xs = (-2600..=2600).map(|i| f64::from(i) * 0.01);
3238 let (x, relative) = worst(xs.map(|x| (x, erfc(x), libm::erfc(x))));
3239 assert!(relative < 2e-7, "erfc({x}) is {relative:.2e} off");
3240 }
3241
3242 /// Z(A, B) = √π·exp(B² − A²)·erfc(B) with B = A + C, so
3243 /// B² − A² = 2BC − C². Below B = −5.01 the kernel is the exponential
3244 /// tail 2√π·exp(2BC − C²).
3245 #[test]
3246 fn the_combined_kernel_matches_an_independent_erfc_down_its_tail() {
3247 for c in [0.05, 0.1, 0.25, 1.0] {
3248 let bs = (-6000..=2600).map(|i| f64::from(i) * 0.01);
3249 let (b, relative) = worst(bs.map(|b| {
3250 let want = SQRT_PI * (2.0 * b * c - c * c).exp() * libm::erfc(b);
3251 (b, gauss_exp_kernel(b - c, b), want)
3252 }));
3253 assert!(
3254 relative < 2e-7,
3255 "C = {c}: Z at B = {b} is {relative:.2e} off"
3256 );
3257 }
3258 }
3259
3260 /// ∫ Z dA = √π/C: integrate by parts with u = A + C,
3261 /// ∫ e^{2Cu} erfc(u) du = e^{C²}/C.
3262 #[test]
3263 fn the_combined_kernel_keeps_its_whole_area() {
3264 for c in [0.05, 0.1, 0.25] {
3265 let step = 0.01;
3266 let low = -50.0 / c;
3267 let count = ((10.0 - low) / step) as usize;
3268 let area: f64 = (0..=count)
3269 .map(|i| {
3270 let a = low + step * i as f64;
3271 let end = if i == 0 || i == count { 0.5 } else { 1.0 };
3272 end * gauss_exp_kernel(a, a + c)
3273 })
3274 .sum::<f64>()
3275 * step;
3276 let want = SQRT_PI / c;
3277 let relative = (area - want).abs() / want;
3278 assert!(
3279 relative < 1e-9,
3280 "C = {c}: area {area:.9} against √π/C = {want:.9}, {relative:.2e} off"
3281 );
3282 }
3283 }
3284
3285 #[test]
3286 fn width_corrected_preserves_centroid_scales_width_and_energy_dependence() {
3287 // asymmetric kernel straddling 0 (peak off-centre), two ref energies.
3288 let offs = vec![-2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0];
3289 let wts = vec![0.1, 0.3, 1.0, 0.8, 0.5, 0.3, 0.1];
3290 let tab = TabulatedResolution::from_kernels(
3291 vec![5.0, 50.0],
3292 vec![(offs.clone(), wts.clone()), (offs.clone(), wts.clone())],
3293 25.0,
3294 )
3295 .unwrap();
3296
3297 // Uniform 2.5x width scale (p=0): centroid fixed, std scales by s0, weights unchanged.
3298 let s0 = 2.5;
3299 let wc = tab.width_corrected(s0, 0.0, 10.0).unwrap();
3300 for (orig, scaled) in tab.kernels().iter().zip(wc.kernels()) {
3301 let (c0, std0) = kernel_centroid_std(&orig.0, &orig.1);
3302 let (c1, std1) = kernel_centroid_std(&scaled.0, &scaled.1);
3303 assert!((c0 - c1).abs() < 1e-12, "centroid moved {c0} -> {c1}");
3304 assert!(
3305 (std1 / std0 - s0).abs() < 1e-12,
3306 "width ratio {} != {s0}",
3307 std1 / std0
3308 );
3309 }
3310 assert_eq!(
3311 tab.kernels()[0].1,
3312 wc.kernels()[0].1,
3313 "weights must be unchanged"
3314 );
3315
3316 // s0=1,p=0 is an exact width-identical copy.
3317 let id = tab.width_corrected(1.0, 0.0, 10.0).unwrap();
3318 assert_eq!(id.kernels()[0].0, tab.kernels()[0].0);
3319
3320 // p<0 -> higher energy is narrower (energy-dependent width).
3321 let wc2 = tab.width_corrected(1.0, -0.5, 10.0).unwrap();
3322 let (_, std_lo) = kernel_centroid_std(&wc2.kernels()[0].0, &wc2.kernels()[0].1); // 5 eV
3323 let (_, std_hi) = kernel_centroid_std(&wc2.kernels()[1].0, &wc2.kernels()[1].1); // 50 eV
3324 assert!(
3325 std_hi < std_lo,
3326 "p<0 should narrow higher E: {std_hi} !< {std_lo}"
3327 );
3328 }
3329
3330 #[test]
3331 fn width_corrected_preserves_trapezoidal_centroid_on_nonuniform_grid() {
3332 // On a NON-uniform offset grid the trapezoidal-weighted centroid (what the
3333 // broadening integral actually integrates against) differs from the plain
3334 // centroid. The width scale must pivot about the former, else the fitted
3335 // width leaks into position. The uniform-grid test above cannot see this —
3336 // there the two centroids coincide.
3337 let offs = vec![-2.0, -1.5, 0.0, 0.5, 3.0]; // deliberately non-uniform
3338 let wts = vec![0.2, 0.6, 1.0, 0.7, 0.2];
3339 let tab =
3340 TabulatedResolution::from_kernels(vec![10.0], vec![(offs.clone(), wts.clone())], 25.0)
3341 .unwrap();
3342
3343 // Trapezoidal-weighted centroid, mirroring broaden_presorted's dt weights.
3344 let trap_centroid = |o: &[f64], w: &[f64]| -> f64 {
3345 let n = o.len();
3346 let dt = |k: usize| -> f64 {
3347 if n <= 1 {
3348 1.0
3349 } else if k == 0 {
3350 o[1] - o[0]
3351 } else if k == n - 1 {
3352 o[k] - o[k - 1]
3353 } else {
3354 (o[k + 1] - o[k - 1]) * 0.5
3355 }
3356 };
3357 let (mut num, mut den) = (0.0, 0.0);
3358 for (k, (&oi, &wi)) in o.iter().zip(w).enumerate() {
3359 let tw = wi * dt(k).abs();
3360 num += oi * tw;
3361 den += tw;
3362 }
3363 num / den
3364 };
3365 let plain_centroid = |o: &[f64], w: &[f64]| -> f64 {
3366 o.iter().zip(w).map(|(a, b)| a * b).sum::<f64>() / w.iter().sum::<f64>()
3367 };
3368
3369 let c_trap_before = trap_centroid(&offs, &wts);
3370 // Sanity: on this grid the trapezoidal and plain centroids genuinely differ,
3371 // so the test would fail under the old plain-centroid pivot.
3372 assert!(
3373 (c_trap_before - plain_centroid(&offs, &wts)).abs() > 1e-3,
3374 "test grid not non-uniform enough"
3375 );
3376
3377 let wc = tab.width_corrected(2.0, 0.0, 10.0).unwrap();
3378 let c_trap_after = trap_centroid(&wc.kernels()[0].0, &wc.kernels()[0].1);
3379 assert!(
3380 (c_trap_after - c_trap_before).abs() < 1e-12,
3381 "integrated centroid leaked under width scale: {c_trap_before} -> {c_trap_after}"
3382 );
3383 }
3384
3385 #[test]
3386 fn width_corrected_zero_weight_block_falls_back_to_zero_pivot() {
3387 // A degenerate all-zero-weight kernel has no centroid; the scale pivots
3388 // about 0 rather than dividing by a zero weight sum.
3389 let tab = TabulatedResolution::from_kernels(
3390 vec![10.0],
3391 vec![(vec![-1.0, 0.0, 2.0], vec![0.0, 0.0, 0.0])],
3392 25.0,
3393 )
3394 .unwrap();
3395 let wc = tab.width_corrected(2.0, 0.0, 10.0).unwrap();
3396 assert_eq!(wc.kernels()[0].0, vec![-2.0, 0.0, 4.0]); // scaled about 0
3397 }
3398
3399 #[test]
3400 fn width_corrected_rejects_invalid_params() {
3401 // Public API: invalid whole-configuration inputs must hard-error up front
3402 // (not silently clamp), since a non-positive s0 reverses the offset order.
3403 let tab = TabulatedResolution::from_kernels(
3404 vec![10.0],
3405 vec![(vec![-1.0, 0.0, 2.0], vec![0.1, 1.0, 0.1])],
3406 25.0,
3407 )
3408 .unwrap();
3409 for (s0, p, e_ref) in [
3410 (0.0, 0.0, 10.0), // s0 == 0
3411 (-1.0, 0.0, 10.0), // s0 < 0
3412 (f64::NAN, 0.0, 10.0), // s0 non-finite
3413 (1.0, f64::NAN, 10.0), // p non-finite
3414 (1.0, 0.0, 0.0), // e_ref == 0
3415 (1.0, 0.0, -5.0), // e_ref < 0
3416 (1.0, 0.0, f64::INFINITY), // e_ref non-finite
3417 ] {
3418 assert!(
3419 matches!(
3420 tab.width_corrected(s0, p, e_ref),
3421 Err(ResolutionError::InvalidWidthCorrection { .. })
3422 ),
3423 "expected InvalidWidthCorrection for s0={s0}, p={p}, e_ref={e_ref}"
3424 );
3425 }
3426 }
3427
3428 #[test]
3429 fn from_kernels_rejects_empty_kernel() {
3430 // An empty kernel passes the length-match check (0 == 0) but makes
3431 // broadening silently fall back to pass-through; reject it up front.
3432 let err = TabulatedResolution::from_kernels(vec![10.0], vec![(vec![], vec![])], 25.0);
3433 assert!(
3434 matches!(err, Err(ResolutionParseError::InvalidFormat(_))),
3435 "empty kernel should be rejected, got {err:?}"
3436 );
3437 // A non-empty kernel still constructs fine.
3438 assert!(
3439 TabulatedResolution::from_kernels(vec![10.0], vec![(vec![0.0], vec![1.0])], 25.0)
3440 .is_ok()
3441 );
3442 // Unsorted offsets are rejected — the broadener walks a monotonic two-
3443 // pointer bracket and derives trapezoidal widths assuming sorted offsets.
3444 let unsorted = TabulatedResolution::from_kernels(
3445 vec![10.0],
3446 vec![(vec![0.0, -1.0, 2.0], vec![0.2, 1.0, 0.2])],
3447 25.0,
3448 );
3449 assert!(
3450 matches!(unsorted, Err(ResolutionParseError::InvalidFormat(_))),
3451 "unsorted offsets should be rejected, got {unsorted:?}"
3452 );
3453 // Duplicate offsets (non-strict) are also rejected.
3454 let dup = TabulatedResolution::from_kernels(
3455 vec![10.0],
3456 vec![(vec![-1.0, 0.0, 0.0, 2.0], vec![0.1, 1.0, 1.0, 0.1])],
3457 25.0,
3458 );
3459 assert!(matches!(dup, Err(ResolutionParseError::InvalidFormat(_))));
3460 }
3461
3462 #[test]
3463 fn from_text_rejects_unsorted_offsets_within_block() {
3464 // The second energy block has an out-of-order offset pair
3465 // (1.0 followed by 0.0): the trapezoidal `dt_width` quadrature
3466 // and the two-pointer bracket walk both assume sorted offsets,
3467 // so the parser must error rather than construct a
3468 // silently-corrupt kernel (same invariant `from_kernels`
3469 // enforces).
3470 let text = "\
3471Resolution file
3472---------------
34735.0 0.0
3474-1.0 0.1
34750.0 1.0
34761.0 0.5
3477
347810.0 0.0
3479-1.0 0.1
34801.0 0.5
34810.0 1.0
3482";
3483 let err = TabulatedResolution::from_text(text, 25.0);
3484 let Err(ResolutionParseError::InvalidFormat(msg)) = err else {
3485 panic!("unsorted offsets must be rejected, got {err:?}");
3486 };
3487 assert!(
3488 msg.contains("Kernel 1") && msg.contains("E = 10 eV"),
3489 "error must name the offending block and reference energy: {msg}"
3490 );
3491 // The same file with sorted offsets parses fine.
3492 let sorted = "\
3493Resolution file
3494---------------
34955.0 0.0
3496-1.0 0.1
34970.0 1.0
34981.0 0.5
3499
350010.0 0.0
3501-1.0 0.1
35020.0 1.0
35031.0 0.5
3504";
3505 assert!(TabulatedResolution::from_text(sorted, 25.0).is_ok());
3506 }
3507
3508 /// The remaining `from_kernels` invariants hold for parsed files too:
3509 /// empty energy blocks (silent pass-through at broaden time), non-finite
3510 /// kernel values (NaN norm bypasses the division guard), and non-finite
3511 /// reference energies (NaN compares false, slipping through the
3512 /// ascending check into the bracketing binary search) must all error.
3513 #[test]
3514 fn from_text_rejects_empty_block_and_non_finite_values() {
3515 // Empty block: header line for E=10 with no data lines.
3516 let empty_block = "\
3517Resolution file
3518---------------
35195.0 0.0
3520-1.0 0.1
35210.0 1.0
3522
352310.0 0.0
3524
3525";
3526 let err = TabulatedResolution::from_text(empty_block, 25.0);
3527 let Err(ResolutionParseError::InvalidFormat(msg)) = err else {
3528 panic!("empty energy block must be rejected, got {err:?}");
3529 };
3530 assert!(
3531 msg.contains("E = 10 eV") && msg.contains("no (offset, weight) points"),
3532 "error must name the empty block: {msg}"
3533 );
3534
3535 // Non-finite kernel weight.
3536 let nan_weight = "\
3537Resolution file
3538---------------
35395.0 0.0
3540-1.0 0.1
35410.0 NaN
35421.0 0.5
3543";
3544 let err = TabulatedResolution::from_text(nan_weight, 25.0);
3545 let Err(ResolutionParseError::InvalidFormat(msg)) = err else {
3546 panic!("non-finite weight must be rejected, got {err:?}");
3547 };
3548 assert!(
3549 msg.contains("non-finite"),
3550 "error must name the non-finite value class: {msg}"
3551 );
3552
3553 // Non-finite kernel OFFSET: must report the finiteness problem,
3554 // not the misleading "must be strictly ascending" (a NaN offset
3555 // fails the ascending comparison too — finiteness is checked
3556 // first so the message stays accurate).
3557 let nan_offset = "\
3558Resolution file
3559---------------
35605.0 0.0
3561-1.0 0.1
3562NaN 1.0
35631.0 0.5
3564";
3565 let err = TabulatedResolution::from_text(nan_offset, 25.0);
3566 let Err(ResolutionParseError::InvalidFormat(msg)) = err else {
3567 panic!("non-finite offset must be rejected, got {err:?}");
3568 };
3569 assert!(
3570 msg.contains("non-finite") && !msg.contains("ascending"),
3571 "NaN offset must report finiteness, not sortedness: {msg}"
3572 );
3573
3574 // Non-finite reference energy.
3575 let nan_energy = "\
3576Resolution file
3577---------------
3578NaN 0.0
3579-1.0 0.1
35800.0 1.0
3581";
3582 let err = TabulatedResolution::from_text(nan_energy, 25.0);
3583 let Err(ResolutionParseError::InvalidFormat(msg)) = err else {
3584 panic!("non-finite reference energy must be rejected, got {err:?}");
3585 };
3586 assert!(
3587 msg.contains("finite"),
3588 "error must name the finiteness requirement: {msg}"
3589 );
3590 }
3591
3592 /// Non-positive reference energies must be rejected by BOTH
3593 /// constructors: the TOF map needs E > 0, and the width
3594 /// interpolation takes ln(E_ref) — a zero/negative reference would
3595 /// turn every blended weight into NaN, which bypasses the
3596 /// broadener's norm guard and silently disables broadening (a
3597 /// stray `0.0 0.0` line after a blank line in a VENUS/FTS file
3598 /// parses as an energy-block header).
3599 #[test]
3600 fn constructors_reject_non_positive_reference_energies() {
3601 let zero_energy = "\
3602Resolution file
3603---------------
36040.0 0.0
3605-1.0 0.1
36060.0 1.0
36071.0 0.5
3608
360910.0 0.0
3610-1.0 0.1
36110.0 1.0
36121.0 0.5
3613";
3614 let err = TabulatedResolution::from_text(zero_energy, 25.0);
3615 let Err(ResolutionParseError::InvalidFormat(msg)) = err else {
3616 panic!("zero reference energy must be rejected, got {err:?}");
3617 };
3618 assert!(
3619 msg.contains("positive"),
3620 "error must name the positivity requirement: {msg}"
3621 );
3622
3623 let err = TabulatedResolution::from_kernels(
3624 vec![-10.0, 10.0],
3625 vec![
3626 (vec![-1.0, 0.0, 1.0], vec![0.1, 1.0, 0.1]),
3627 (vec![-1.0, 0.0, 1.0], vec![0.1, 1.0, 0.1]),
3628 ],
3629 25.0,
3630 );
3631 let Err(ResolutionParseError::InvalidFormat(msg)) = err else {
3632 panic!("negative reference energy must be rejected, got {err:?}");
3633 };
3634 assert!(
3635 msg.contains("positive"),
3636 "error must name the positivity requirement: {msg}"
3637 );
3638 }
3639
3640 /// Negative kernel weights (no physical meaning; the broadener
3641 /// skips them while the width moments would otherwise fold them
3642 /// in) must be rejected by BOTH constructors.
3643 #[test]
3644 fn constructors_reject_negative_weights() {
3645 let neg_weight = "\
3646Resolution file
3647---------------
36485.0 0.0
3649-1.0 0.1
36500.0 1.0
36511.0 -0.2
3652";
3653 let err = TabulatedResolution::from_text(neg_weight, 25.0);
3654 let Err(ResolutionParseError::InvalidFormat(msg)) = err else {
3655 panic!("negative weight must be rejected, got {err:?}");
3656 };
3657 assert!(
3658 msg.contains("negative weights"),
3659 "error must name the negative-weight problem: {msg}"
3660 );
3661
3662 let err = TabulatedResolution::from_kernels(
3663 vec![10.0],
3664 vec![(vec![-1.0, 0.0, 1.0], vec![-1.0, 0.2, -1.0])],
3665 25.0,
3666 );
3667 let Err(ResolutionParseError::InvalidFormat(msg)) = err else {
3668 panic!("negative weights must be rejected, got {err:?}");
3669 };
3670 assert!(
3671 msg.contains("negative weights"),
3672 "error must name the negative-weight problem: {msg}"
3673 );
3674 }
3675
3676 #[test]
3677 fn interpolated_kernel_blend_stays_ascending_and_mode_anchored() {
3678 // Two equal-length kernels with their peak at offset 0 — a loaded
3679 // UDR file's anchoring — through the width-normalized shape blend
3680 // that every between-reference energy takes. The blend must stay
3681 // strictly ascending (the sorted invariant the broadener relies on)
3682 // and leave whatever sits at 0 there.
3683 let off_lo = vec![-1.0, -0.4, 0.0, 0.6, 1.5, 3.0];
3684 let off_hi = vec![-0.5, -0.2, 0.0, 0.3, 0.8, 1.6]; // narrower, same length
3685 let wts = vec![0.1, 0.5, 1.0, 0.6, 0.3, 0.1]; // mode at index 2 (offset 0)
3686 let tab = TabulatedResolution::from_kernels(
3687 vec![5.0, 50.0],
3688 vec![(off_lo, wts.clone()), (off_hi, wts.clone())],
3689 25.0,
3690 )
3691 .unwrap();
3692 let (blended, weights) = tab.interpolated_kernel(15.0); // between 5 and 50 eV
3693 assert!(
3694 blended.windows(2).all(|w| w[0] < w[1]),
3695 "blended offsets not strictly ascending: {blended:?}"
3696 );
3697 let kmax = (0..weights.len())
3698 .max_by(|&a, &b| weights[a].total_cmp(&weights[b]))
3699 .unwrap();
3700 assert!(
3701 blended[kmax].abs() < 1e-9,
3702 "mode not anchored at offset 0: {}",
3703 blended[kmax]
3704 );
3705 }
3706
3707 /// For a self-similar family (each block a width-scaled copy of one
3708 /// asymmetric template), the width-normalized shapes agree, so the
3709 /// blended kernel's trapezoidal width must equal the geometric
3710 /// interpolation `σ_lo·(σ_hi/σ_lo)^frac` — near-exactly (the scaled
3711 /// grids coincide point-for-point in z, so the merge degenerates to
3712 /// the template shape at the target width). An interior reference
3713 /// energy must return that block bitwise (exact-hit arm).
3714 ///
3715 /// SCOPE NOTE: this measures the blend with the implementation's
3716 /// own `trapezoidal_moments` and target formula, so it pins the
3717 /// merge machinery against its coded target (plus the chord
3718 /// separation below), not the physical width law independently —
3719 /// that independent, end-to-end anchor lives in
3720 /// `tests/kernel_width_interpolation.rs` and the pytest port,
3721 /// which measure the APPLIED broadening of parsed kernels against
3722 /// the analytic power law.
3723 #[test]
3724 fn interpolated_kernel_width_follows_power_law_for_self_similar_blocks() {
3725 let template_off = [-1.0, -0.4, 0.0, 0.8, 2.0, 4.0];
3726 let template_w = vec![0.05, 0.5, 1.0, 0.6, 0.2, 0.02];
3727 // σ ∝ E^{-1/2} scaling across refs 10 / 100 / 1000 eV.
3728 let scale = |e: f64| (e / 10.0f64).powf(-0.5);
3729 let block = |e: f64| -> (Vec<f64>, Vec<f64>) {
3730 (
3731 template_off.iter().map(|&o| o * scale(e)).collect(),
3732 template_w.clone(),
3733 )
3734 };
3735 let tab = TabulatedResolution::from_kernels(
3736 vec![10.0, 100.0, 1000.0],
3737 vec![block(10.0), block(100.0), block(1000.0)],
3738 25.0,
3739 )
3740 .unwrap();
3741
3742 // Interior exact hit: the middle reference comes back bitwise.
3743 let (off_ref, w_ref) = test_support::interpolated_kernel(&tab, 100.0);
3744 let (exp_off, exp_w) = block(100.0);
3745 assert_eq!(off_ref.len(), exp_off.len());
3746 for (a, b) in off_ref.iter().zip(&exp_off) {
3747 assert_eq!(
3748 a.to_bits(),
3749 b.to_bits(),
3750 "exact-hit offsets must be bitwise"
3751 );
3752 }
3753 for (a, b) in w_ref.iter().zip(&exp_w) {
3754 assert_eq!(
3755 a.to_bits(),
3756 b.to_bits(),
3757 "exact-hit weights must be bitwise"
3758 );
3759 }
3760
3761 // Between references: measured width equals the geometric law.
3762 let (off_lo, w_lo) = block(10.0);
3763 let (off_hi, w_hi) = block(100.0);
3764 let (_, s_lo) = trapezoidal_moments(&off_lo, &w_lo);
3765 let (_, s_hi) = trapezoidal_moments(&off_hi, &w_hi);
3766 for e in [16.0f64, 25.0, 40.0, 70.0] {
3767 let frac = (e.ln() - 10.0f64.ln()) / (100.0f64.ln() - 10.0f64.ln());
3768 let expected = s_lo * (s_hi / s_lo).powf(frac);
3769 let (offs, ws) = test_support::interpolated_kernel(&tab, e);
3770 let (_, got) = trapezoidal_moments(&offs, &ws);
3771 assert!(
3772 (got - expected).abs() / expected < 1e-9,
3773 "blended width at {e} eV: got {got}, expected {expected} \
3774 (the pre-fix arithmetic chord gave {})",
3775 s_lo + frac * (s_hi - s_lo)
3776 );
3777 // Non-vacuity: the removed chord error is resolvable at
3778 // this tolerance (σ_hi/σ_lo = 10^{-1/2} → several % apart).
3779 assert!(
3780 (s_lo + frac * (s_hi - s_lo) - expected).abs() / expected > 1e-2,
3781 "fixture must separate chord from geometric law at {e} eV"
3782 );
3783 }
3784 }
3785
3786 /// Identical bracketing blocks must come back bitwise-identical
3787 /// between the references (scale ratios exactly 1.0; the blend form
3788 /// `a + frac·(b − a)` is exact when a == b).
3789 #[test]
3790 fn interpolated_kernel_is_bitwise_identity_for_identical_blocks() {
3791 let off = vec![-1.0, -0.4, 0.0, 0.8, 2.0, 4.0];
3792 let w = vec![0.05, 0.5, 1.0, 0.6, 0.2, 0.02];
3793 let tab = TabulatedResolution::from_kernels(
3794 vec![5.0, 500.0],
3795 vec![(off.clone(), w.clone()), (off.clone(), w.clone())],
3796 25.0,
3797 )
3798 .unwrap();
3799 let (offs, ws) = test_support::interpolated_kernel(&tab, 42.0);
3800 assert_eq!(offs.len(), off.len());
3801 for (a, b) in offs.iter().zip(&off) {
3802 assert_eq!(a.to_bits(), b.to_bits(), "identity offsets must be bitwise");
3803 }
3804 for (a, b) in ws.iter().zip(&w) {
3805 assert_eq!(a.to_bits(), b.to_bits(), "identity weights must be bitwise");
3806 }
3807 }
3808
3809 /// A NaN target energy (unreachable through validated public paths,
3810 /// but defended anyway) must yield a well-formed reference clone —
3811 /// never NaN offsets that break the broadener's invariants.
3812 #[test]
3813 fn interpolated_kernel_nan_energy_clamps_to_lowest_reference() {
3814 let off = vec![-1.0, 0.0, 2.0];
3815 let w = vec![0.3, 1.0, 0.2];
3816 let tab = TabulatedResolution::from_kernels(
3817 vec![10.0, 1000.0],
3818 vec![(off.clone(), w.clone()), (off.clone(), w.clone())],
3819 25.0,
3820 )
3821 .unwrap();
3822 let (offs, ws) = test_support::interpolated_kernel(&tab, f64::NAN);
3823 assert_eq!(offs, off);
3824 assert_eq!(ws, w);
3825 }
3826
3827 /// Degenerate blocks (single-point → σ = 0) cannot be width-scaled;
3828 /// the nearer reference is cloned instead.
3829 #[test]
3830 fn interpolated_kernel_degenerate_blocks_fall_back_to_nearest() {
3831 let tab = TabulatedResolution::from_kernels(
3832 vec![10.0, 1000.0],
3833 vec![(vec![0.0], vec![1.0]), (vec![0.5], vec![1.0])],
3834 25.0,
3835 )
3836 .unwrap();
3837 // frac < 0.5 → lower block; frac > 0.5 → upper block.
3838 let (lo_off, _) = test_support::interpolated_kernel(&tab, 15.0);
3839 assert_eq!(lo_off, vec![0.0]);
3840 let (hi_off, _) = test_support::interpolated_kernel(&tab, 700.0);
3841 assert_eq!(hi_off, vec![0.5]);
3842 }
3843
3844 // ── Smoke tests for the test_support oracles (`interp_spectrum` +
3845 // `broaden_presorted_reference`). The 7+ bit-exact tests below
3846 // exercise the math thoroughly; these smoke tests just pin the
3847 // boundary-condition return-shape behavior of the oracles so a
3848 // future refactor that breaks empty-input or out-of-range
3849 // handling fails loudly rather than only via bit-exact diffs.
3850
3851 #[test]
3852 fn test_support_interp_spectrum_empty_returns_none() {
3853 assert_eq!(test_support::interp_spectrum(&[], &[], 1.0), None);
3854 }
3855
3856 #[test]
3857 fn test_support_interp_spectrum_out_of_range_returns_none() {
3858 let energies = [1.0, 2.0, 3.0];
3859 let spectrum = [10.0, 20.0, 30.0];
3860 assert_eq!(
3861 test_support::interp_spectrum(&energies, &spectrum, 0.5),
3862 None
3863 );
3864 assert_eq!(
3865 test_support::interp_spectrum(&energies, &spectrum, 3.5),
3866 None
3867 );
3868 }
3869
3870 #[test]
3871 fn test_support_broaden_presorted_reference_empty_returns_empty() {
3872 let tab = test_support::trivial_tabulated_resolution(25.0);
3873 let out = test_support::broaden_presorted_reference(&tab, &[], &[]);
3874 assert!(out.is_empty());
3875 }
3876
3877 #[test]
3878 fn test_tof_factor_consistency() {
3879 // Verify our TOF_FACTOR matches the constants module.
3880 let e = 10.0; // eV
3881 let l = 25.0; // meters
3882 let tof_constants = constants::energy_to_tof(e, l);
3883 let tof_ours = TOF_FACTOR * l / e.sqrt();
3884 let rel_diff = (tof_constants - tof_ours).abs() / tof_constants;
3885 assert!(
3886 rel_diff < 1e-10,
3887 "TOF mismatch: constants={}, ours={}, diff={:.4}%",
3888 tof_constants,
3889 tof_ours,
3890 rel_diff * 100.0
3891 );
3892 }
3893
3894 #[test]
3895 fn test_resolution_width_scaling() {
3896 let params = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
3897
3898 // Resolution width should increase with energy.
3899 let w1 = params.gaussian_width(1.0);
3900 let w10 = params.gaussian_width(10.0);
3901 let w100 = params.gaussian_width(100.0);
3902
3903 assert!(w10 > w1, "Width should increase with energy");
3904 assert!(w100 > w10, "Width should increase with energy");
3905
3906 // At low energies, timing dominates: ΔE ∝ E^(3/2)
3907 // At high energies, path dominates: ΔE ∝ E
3908 // The ratio ΔE(10)/ΔE(1) should be between 10 and 31.6 (= 10^1.5)
3909 let ratio = w10 / w1;
3910 assert!(
3911 ratio > 5.0 && ratio < 40.0,
3912 "Width ratio = {}, expected between 10 and 31.6",
3913 ratio
3914 );
3915 }
3916
3917 #[test]
3918 fn test_zero_width_passthrough() {
3919 // If resolution parameters are zero, output should equal input.
3920 let energies = vec![1.0, 2.0, 3.0, 4.0, 5.0];
3921 let xs = vec![10.0, 20.0, 30.0, 20.0, 10.0];
3922 let params = ResolutionParams::new(25.0, 0.0, 0.0, 0.0).unwrap();
3923 let broadened = resolution_broaden(&energies, &xs, ¶ms).unwrap();
3924 assert_eq!(broadened, xs);
3925 }
3926
3927 #[test]
3928 fn test_broadening_reduces_peak() {
3929 // Resolution broadening should reduce peak heights and fill valleys.
3930 let n = 1001;
3931 let energies: Vec<f64> = (0..n).map(|i| 5.0 + (i as f64) * 0.01).collect();
3932 let center = 10.0;
3933 let gamma: f64 = 0.1; // Resonance width
3934 let xs: Vec<f64> = energies
3935 .iter()
3936 .map(|&e| {
3937 let de = e - center;
3938 1000.0 * (gamma / 2.0).powi(2) / (de * de + (gamma / 2.0).powi(2))
3939 })
3940 .collect();
3941
3942 let params = ResolutionParams::new(25.0, 5.0, 0.01, 0.0).unwrap();
3943 let broadened = resolution_broaden(&energies, &xs, ¶ms).unwrap();
3944
3945 let orig_peak = xs.iter().cloned().fold(0.0_f64, f64::max);
3946 let broad_peak = broadened.iter().cloned().fold(0.0_f64, f64::max);
3947
3948 assert!(
3949 broad_peak < orig_peak,
3950 "Broadened peak ({}) should be < original ({})",
3951 broad_peak,
3952 orig_peak
3953 );
3954 assert!(
3955 broad_peak > 1.0,
3956 "Broadened peak ({}) should still be substantial",
3957 broad_peak
3958 );
3959 }
3960
3961 #[test]
3962 fn test_broadening_conserves_area() {
3963 // Resolution broadening should approximately conserve the area
3964 // under the cross-section curve.
3965 let n = 2001;
3966 let energies: Vec<f64> = (0..n).map(|i| 5.0 + (i as f64) * 0.005).collect();
3967 let center = 10.0;
3968 let gamma: f64 = 0.5;
3969 let xs: Vec<f64> = energies
3970 .iter()
3971 .map(|&e| {
3972 let de = e - center;
3973 1000.0 * (gamma / 2.0).powi(2) / (de * de + (gamma / 2.0).powi(2))
3974 })
3975 .collect();
3976
3977 let params = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
3978 let broadened = resolution_broaden(&energies, &xs, ¶ms).unwrap();
3979
3980 // Trapezoidal area
3981 let area_orig: f64 = (0..n - 1)
3982 .map(|i| 0.5 * (xs[i] + xs[i + 1]) * (energies[i + 1] - energies[i]))
3983 .sum();
3984 let area_broad: f64 = (0..n - 1)
3985 .map(|i| 0.5 * (broadened[i] + broadened[i + 1]) * (energies[i + 1] - energies[i]))
3986 .sum();
3987
3988 let rel_diff = (area_orig - area_broad).abs() / area_orig;
3989 assert!(
3990 rel_diff < 0.02,
3991 "Area not conserved: orig={:.2}, broad={:.2}, rel_diff={:.4}",
3992 area_orig,
3993 area_broad,
3994 rel_diff
3995 );
3996 }
3997
3998 #[test]
3999 fn test_gaussian_broadening_analytical() {
4000 // Broadening a Gaussian with a Gaussian should give a wider Gaussian.
4001 //
4002 // Input: exp(-x²/(2σ₁²)) with σ₁ = 0.5 eV (standard Gaussian form)
4003 // Kernel: exp(-x²/W²) with W = 0.3 eV → std dev σ₂ = W/√2 = 0.2121 eV
4004 // Output: Gaussian with σ_out = √(σ₁² + σ₂²) = √(0.25 + 0.045) = 0.543 eV
4005 //
4006 // Note: kernel width varies slightly with energy (σ_E ∝ E for the
4007 // path-length contribution), so we allow ~5% tolerance.
4008 let n = 2001;
4009 let center = 10.0;
4010 let sigma_input = 0.5; // eV (standard deviation)
4011 let energies: Vec<f64> = (0..n).map(|i| 5.0 + (i as f64) * 0.005).collect();
4012 let xs: Vec<f64> = energies
4013 .iter()
4014 .map(|&e| {
4015 let de = e - center;
4016 1000.0 * (-de * de / (2.0 * sigma_input * sigma_input)).exp()
4017 })
4018 .collect();
4019
4020 // Set delta_l such that W = gaussian_width(E=10) ≈ 0.3 eV.
4021 // W = 2·ΔL·E/L, so ΔL = W·L/(2E) = 0.3×25/(20) = 0.375 m
4022 let w_kernel = 0.3; // Kernel parameter W (exp(-x²/W²))
4023 let params =
4024 ResolutionParams::new(25.0, 0.0, w_kernel * 25.0 / (2.0 * center), 0.0).unwrap();
4025
4026 // Verify kernel W at center energy
4027 let w_at_center = params.gaussian_width(center);
4028 assert!(
4029 (w_at_center - w_kernel).abs() / w_kernel < 0.01,
4030 "Kernel W at center: {}, expected {}",
4031 w_at_center,
4032 w_kernel
4033 );
4034
4035 let broadened = resolution_broaden(&energies, &xs, ¶ms).unwrap();
4036
4037 // Kernel std dev = W/√2
4038 let sigma_kernel = w_kernel / 2.0_f64.sqrt();
4039 let sigma_expected = (sigma_input * sigma_input + sigma_kernel * sigma_kernel).sqrt();
4040 let fwhm_expected = 2.0 * (2.0_f64.ln() * 2.0).sqrt() * sigma_expected;
4041
4042 // Measure FWHM from the broadened output
4043 let peak_idx = broadened
4044 .iter()
4045 .enumerate()
4046 .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
4047 .unwrap()
4048 .0;
4049 let peak_val = broadened[peak_idx];
4050 let half_max = peak_val / 2.0;
4051
4052 let mut left_hm = energies[0];
4053 for i in (0..peak_idx).rev() {
4054 if broadened[i] < half_max {
4055 let t = (half_max - broadened[i]) / (broadened[i + 1] - broadened[i]);
4056 left_hm = energies[i] + t * (energies[i + 1] - energies[i]);
4057 break;
4058 }
4059 }
4060 let mut right_hm = energies[n - 1];
4061 for i in peak_idx..n - 1 {
4062 if broadened[i + 1] < half_max {
4063 let t = (half_max - broadened[i]) / (broadened[i + 1] - broadened[i]);
4064 right_hm = energies[i] + t * (energies[i + 1] - energies[i]);
4065 break;
4066 }
4067 }
4068
4069 let fwhm_measured = right_hm - left_hm;
4070 let rel_err = (fwhm_measured - fwhm_expected).abs() / fwhm_expected;
4071
4072 assert!(
4073 rel_err < 0.05,
4074 "FWHM: measured={:.4}, expected={:.4}, rel_err={:.2}%",
4075 fwhm_measured,
4076 fwhm_expected,
4077 rel_err * 100.0
4078 );
4079 }
4080
4081 #[test]
4082 fn test_venus_typical_resolution() {
4083 // Verify resolution width for typical VENUS parameters.
4084 // VENUS: L ≈ 25 m, Δt ≈ 10 μs (pulsed source), ΔL ≈ 0.01 m
4085 let params = ResolutionParams::new(25.0, 10.0, 0.01, 0.0).unwrap();
4086
4087 // At 1 eV: ΔE/E should be small (good resolution)
4088 let de_1 = params.gaussian_width(1.0);
4089 let de_over_e_1 = de_1 / 1.0;
4090 assert!(
4091 de_over_e_1 < 0.05,
4092 "ΔE/E at 1 eV = {:.4}, should be < 5%",
4093 de_over_e_1
4094 );
4095
4096 // At 100 eV: resolution degrades
4097 let de_100 = params.gaussian_width(100.0);
4098 let de_over_e_100 = de_100 / 100.0;
4099 assert!(
4100 de_over_e_100 > de_over_e_1,
4101 "Resolution should degrade at higher energies"
4102 );
4103 }
4104
4105 #[test]
4106 fn test_unsorted_energies_returns_error() {
4107 let energies = vec![1.0, 3.0, 2.0, 4.0]; // not sorted
4108 let xs = vec![10.0, 30.0, 20.0, 40.0];
4109 let params = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
4110 let result = resolution_broaden(&energies, &xs, ¶ms);
4111 assert!(result.is_err());
4112 assert!(matches!(
4113 result.unwrap_err(),
4114 ResolutionError::UnsortedEnergies
4115 ));
4116 }
4117
4118 #[test]
4119 fn test_length_mismatch_returns_error() {
4120 let energies = vec![1.0, 2.0, 3.0];
4121 let xs = vec![10.0, 20.0]; // wrong length
4122 let params = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
4123 let result = resolution_broaden(&energies, &xs, ¶ms);
4124 assert!(result.is_err());
4125 assert!(matches!(
4126 result.unwrap_err(),
4127 ResolutionError::LengthMismatch {
4128 energies: 3,
4129 data: 2
4130 }
4131 ));
4132 }
4133
4134 // --- ResolutionParams validation tests ---
4135
4136 #[test]
4137 fn test_resolution_params_valid() {
4138 let p = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
4139 assert!((p.flight_path_m() - 25.0).abs() < 1e-15);
4140 assert!((p.delta_t_us() - 1.0).abs() < 1e-15);
4141 assert!((p.delta_l_m() - 0.01).abs() < 1e-15);
4142 }
4143
4144 #[test]
4145 fn test_resolution_params_rejects_zero_flight_path() {
4146 let err = ResolutionParams::new(0.0, 1.0, 0.01, 0.0).unwrap_err();
4147 assert_eq!(err, ResolutionParamsError::InvalidFlightPath(0.0));
4148 }
4149
4150 #[test]
4151 fn test_resolution_params_rejects_negative_flight_path() {
4152 let err = ResolutionParams::new(-1.0, 1.0, 0.01, 0.0).unwrap_err();
4153 assert_eq!(err, ResolutionParamsError::InvalidFlightPath(-1.0));
4154 }
4155
4156 #[test]
4157 fn test_resolution_params_rejects_nan_flight_path() {
4158 let err = ResolutionParams::new(f64::NAN, 1.0, 0.01, 0.0).unwrap_err();
4159 assert!(matches!(err, ResolutionParamsError::InvalidFlightPath(_)));
4160 }
4161
4162 #[test]
4163 fn test_resolution_params_rejects_negative_delta_t() {
4164 let err = ResolutionParams::new(25.0, -1.0, 0.01, 0.0).unwrap_err();
4165 assert_eq!(err, ResolutionParamsError::InvalidDeltaT(-1.0));
4166 }
4167
4168 #[test]
4169 fn test_resolution_params_rejects_nan_delta_t() {
4170 let err = ResolutionParams::new(25.0, f64::NAN, 0.01, 0.0).unwrap_err();
4171 assert!(matches!(err, ResolutionParamsError::InvalidDeltaT(_)));
4172 }
4173
4174 #[test]
4175 fn test_resolution_params_rejects_negative_delta_l() {
4176 let err = ResolutionParams::new(25.0, 1.0, -0.01, 0.0).unwrap_err();
4177 assert_eq!(err, ResolutionParamsError::InvalidDeltaL(-0.01));
4178 }
4179
4180 #[test]
4181 fn test_resolution_params_rejects_inf_delta_l() {
4182 let err = ResolutionParams::new(25.0, 1.0, f64::INFINITY, 0.0).unwrap_err();
4183 assert!(matches!(err, ResolutionParamsError::InvalidDeltaL(_)));
4184 }
4185
4186 #[test]
4187 fn test_resolution_params_rejects_negative_delta_e() {
4188 let err = ResolutionParams::new(25.0, 1.0, 0.01, -0.05).unwrap_err();
4189 assert_eq!(err, ResolutionParamsError::InvalidDeltaE(-0.05));
4190 }
4191
4192 #[test]
4193 fn test_resolution_params_rejects_nan_delta_e() {
4194 let err = ResolutionParams::new(25.0, 1.0, 0.01, f64::NAN).unwrap_err();
4195 assert!(matches!(err, ResolutionParamsError::InvalidDeltaE(_)));
4196 }
4197
4198 #[test]
4199 fn test_resolution_params_accepts_zero_delta_e() {
4200 let p = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
4201 assert!((p.delta_e_us() - 0.0).abs() < 1e-15);
4202 assert!(!p.has_exponential_tail());
4203 }
4204
4205 // ─── broaden_presorted bit-exact equivalence harness ─────────────────────
4206 //
4207 // The optimized broaden_presorted uses a two-pointer walk instead of
4208 // binary search inside the inner convolution loop. These tests pin
4209 // the math: the same formula, in the same order, must yield bit-exact
4210 // output against a canonical reference implementation that preserves
4211 // the pre-optimization code path.
4212
4213 // The `interp_spectrum` + `broaden_presorted_reference` oracles
4214 // were promoted to `test_support` so the integration tests
4215 // (`tests/venus_usr_resolution{,_microbench}.rs`) share the same
4216 // byte-identical reference. Imported below.
4217 use super::test_support::broaden_presorted_reference;
4218
4219 /// Synthetic TabulatedResolution with 3 reference energies and a
4220 /// triangular kernel of varying widths. Deterministic, no I/O.
4221 fn synthetic_tab_resolution() -> TabulatedResolution {
4222 fn triangle(width_us: f64, n: usize) -> (Vec<f64>, Vec<f64>) {
4223 let half = width_us;
4224 let dt_step = 2.0 * half / (n - 1) as f64;
4225 let offsets: Vec<f64> = (0..n).map(|i| -half + i as f64 * dt_step).collect();
4226 let weights: Vec<f64> = offsets
4227 .iter()
4228 .map(|&dt| (1.0 - dt.abs() / half).max(0.0))
4229 .collect();
4230 (offsets, weights)
4231 }
4232 TabulatedResolution {
4233 ref_energies: Arc::new(vec![5.0, 50.0, 500.0]),
4234 kernels: Arc::new(vec![
4235 triangle(0.5, 31),
4236 triangle(1.0, 41),
4237 triangle(2.0, 51),
4238 ]),
4239 flight_path_m: 25.0,
4240 }
4241 }
4242
4243 fn assert_bit_exact(reference: &[f64], actual: &[f64], label: &str) {
4244 assert_eq!(reference.len(), actual.len(), "{label}: length mismatch");
4245 for (i, (&a, &b)) in reference.iter().zip(actual.iter()).enumerate() {
4246 assert_eq!(
4247 a.to_bits(),
4248 b.to_bits(),
4249 "{label}: element {i} mismatch: reference={a:.17e} actual={b:.17e}"
4250 );
4251 }
4252 }
4253
4254 #[test]
4255 fn test_broaden_presorted_bit_exact_synthetic_uniform() {
4256 let tab = synthetic_tab_resolution();
4257 // Uniform log-spaced grid typical of VENUS analysis.
4258 let energies: Vec<f64> = (0..401).map(|i| 7.0 + i as f64 * 0.4825).collect();
4259 // Triangular dip + smooth background (resonance-like spectrum).
4260 let spectrum: Vec<f64> = energies
4261 .iter()
4262 .enumerate()
4263 .map(|(i, &e)| 0.9 - 0.7 * (-((e - 50.0).powi(2) / 4.0)).exp() + 0.001 * (i as f64))
4264 .collect();
4265
4266 let reference = broaden_presorted_reference(&tab, &energies, &spectrum);
4267 let actual = tab.broaden_presorted(&energies, &spectrum);
4268 assert_bit_exact(&reference, &actual, "synthetic_uniform");
4269 }
4270
4271 #[test]
4272 fn test_broaden_presorted_bit_exact_synthetic_nonuniform() {
4273 let tab = synthetic_tab_resolution();
4274 // Non-uniform: denser near 6.674 eV (resonance-like), sparser far away.
4275 let energies: Vec<f64> = {
4276 let mut e = Vec::new();
4277 for i in 0..200 {
4278 e.push(5.0 + (i as f64) * 0.05);
4279 }
4280 for i in 0..100 {
4281 e.push(15.0 + (i as f64) * 0.5);
4282 }
4283 for i in 0..50 {
4284 e.push(65.0 + (i as f64) * 2.0);
4285 }
4286 e
4287 };
4288 let spectrum: Vec<f64> = energies
4289 .iter()
4290 .map(|&e| 1.0 - 0.5 * (-((e - 6.674).powi(2) / 0.1)).exp())
4291 .collect();
4292
4293 let reference = broaden_presorted_reference(&tab, &energies, &spectrum);
4294 let actual = tab.broaden_presorted(&energies, &spectrum);
4295 assert_bit_exact(&reference, &actual, "synthetic_nonuniform");
4296 }
4297
4298 #[test]
4299 fn test_broaden_presorted_bit_exact_constant_spectrum() {
4300 // Constant spectrum must pass through unchanged (within trapezoid
4301 // normalization) — preserves integral exactly.
4302 let tab = synthetic_tab_resolution();
4303 let energies: Vec<f64> = (0..501).map(|i| 1.0 + i as f64 * 0.5).collect();
4304 let spectrum = vec![0.42f64; energies.len()];
4305
4306 let reference = broaden_presorted_reference(&tab, &energies, &spectrum);
4307 let actual = tab.broaden_presorted(&energies, &spectrum);
4308 assert_bit_exact(&reference, &actual, "constant_spectrum");
4309 }
4310
4311 #[test]
4312 fn test_broaden_presorted_bit_exact_short_grid() {
4313 // Edge case: 2-point grid. Exercises the smallest grid that has
4314 // a valid (lo, hi) bracket — tests bracket_hi bounds handling.
4315 let tab = synthetic_tab_resolution();
4316 let energies = vec![10.0, 12.0];
4317 let spectrum = vec![0.5, 0.8];
4318
4319 let reference = broaden_presorted_reference(&tab, &energies, &spectrum);
4320 let actual = tab.broaden_presorted(&energies, &spectrum);
4321 assert_bit_exact(&reference, &actual, "short_grid");
4322 }
4323
4324 #[test]
4325 fn test_broaden_presorted_bit_exact_single_point_grid() {
4326 // Edge case: 1-point grid. Exercises the n == 1 early-return
4327 // pass-through guard that the optimized path adds (no bracket
4328 // available for interpolation).
4329 let tab = synthetic_tab_resolution();
4330 let energies = vec![10.0];
4331 let spectrum = vec![0.5];
4332
4333 let reference = broaden_presorted_reference(&tab, &energies, &spectrum);
4334 let actual = tab.broaden_presorted(&energies, &spectrum);
4335 assert_bit_exact(&reference, &actual, "single_point_grid");
4336 }
4337
4338 #[test]
4339 fn test_broaden_presorted_bit_exact_exact_equality_target() {
4340 // Regression: exercise the tie-break case where `e_prime` lands
4341 // exactly on a grid point. The kernel has a point at dt=0, so
4342 // `e_prime == energies[i]` exactly at the center kernel offset
4343 // for every target `i`. The optimized path must match the
4344 // reference's upper-bound binary-search semantics bit-exactly.
4345 let tab = synthetic_tab_resolution();
4346 // Irregular-spacing grid so the spectrum interp at the equality
4347 // point isn't trivially reducible to the input value.
4348 let mut energies: Vec<f64> = Vec::new();
4349 let mut e = 3.0f64;
4350 for k in 0..800 {
4351 energies.push(e);
4352 e += 0.05 + 0.01 * (k as f64).sin();
4353 }
4354 // Spectrum with large local gradient so `a + (b - a)` vs `b`
4355 // would diverge at 1 ULP if the tie-break were wrong.
4356 let spectrum: Vec<f64> = energies
4357 .iter()
4358 .map(|&e| 1.0e10 * (-(((e - 6.0) / 0.2).powi(2))).exp() + 1.0e-10 * e)
4359 .collect();
4360
4361 let reference = broaden_presorted_reference(&tab, &energies, &spectrum);
4362 let actual = tab.broaden_presorted(&energies, &spectrum);
4363 assert_bit_exact(&reference, &actual, "exact_equality_target");
4364 }
4365
4366 #[test]
4367 fn test_broaden_presorted_bit_exact_random_spectrum() {
4368 // Random spectrum with varied magnitudes exercises the interpolation
4369 // arithmetic across sign changes and scales.
4370 let tab = synthetic_tab_resolution();
4371 let energies: Vec<f64> = (0..1001).map(|i| 2.0 + i as f64 * 0.2).collect();
4372 // Deterministic pseudo-random via a simple LCG (no external dep).
4373 let mut state: u64 = 0xDEAD_BEEF_CAFE_BABE;
4374 let spectrum: Vec<f64> = energies
4375 .iter()
4376 .map(|_| {
4377 state = state
4378 .wrapping_mul(6364136223846793005)
4379 .wrapping_add(1442695040888963407);
4380 let f = ((state >> 33) as f64) / (u32::MAX as f64);
4381 f * 2.0 - 1.0
4382 })
4383 .collect();
4384
4385 let reference = broaden_presorted_reference(&tab, &energies, &spectrum);
4386 let actual = tab.broaden_presorted(&energies, &spectrum);
4387 assert_bit_exact(&reference, &actual, "random_spectrum");
4388 }
4389
4390 // ---------------------------------------------------------------
4391 // `crates/nereids-physics/tests/venus_usr_resolution.rs`
4392 // (`test_broaden_presorted_bit_exact_on_venus_usr`) — see issue
4393 //. The integration test parses a synthetic SAMMY USR-format
4394 // kernel via `common::synthetic_venus_usr_tab()` (the real VENUS
4395 // BL10 fixture is not approved for public release; ).
4396 // ---------------------------------------------------------------
4397
4398 #[test]
4399 fn test_plan_reuse_bit_exact_across_multiple_spectra() {
4400 // Core promise of ResolutionPlan: building the plan once and
4401 // applying it to K different spectra must yield the same output
4402 // as K independent `broaden_presorted` calls.
4403 let tab = synthetic_tab_resolution();
4404 let energies: Vec<f64> = (0..401).map(|i| 7.0 + i as f64 * 0.4825).collect();
4405
4406 // Build plan ONCE.
4407 let plan = tab.plan(&energies).expect("sorted grid must validate");
4408 assert_eq!(plan.len(), energies.len());
4409 assert_eq!(plan.target_energies(), &energies[..]);
4410
4411 // Apply across 5 varied spectra.
4412 let mut state: u64 = 0xCAFE_BABE_DEAD_BEEF;
4413 for spec_idx in 0..5 {
4414 let spectrum: Vec<f64> = energies
4415 .iter()
4416 .enumerate()
4417 .map(|(i, &e)| {
4418 state = state
4419 .wrapping_mul(6364136223846793005)
4420 .wrapping_add(1442695040888963407);
4421 let noise = ((state >> 33) as f64) / (u32::MAX as f64);
4422 // Varied magnitudes and shapes per spectrum to catch
4423 // spectrum-dependent arithmetic drift.
4424 (10.0f64).powi(spec_idx - 2) * (1.0 - 0.5 * noise)
4425 + 0.3 * (-((e - 50.0).powi(2) / 4.0)).exp()
4426 + (spec_idx as f64) * 1e-8 * (i as f64)
4427 })
4428 .collect();
4429
4430 let via_plan = plan.apply(&spectrum);
4431 let via_reference = broaden_presorted_reference(&tab, &energies, &spectrum);
4432 assert_bit_exact(
4433 &via_reference,
4434 &via_plan,
4435 &format!("plan_reuse[spec_idx={spec_idx}]"),
4436 );
4437 }
4438 }
4439
4440 #[test]
4441 fn test_plan_passthrough_cases() {
4442 // n == 0, n == 1, and e <= 0.0 must all produce the same
4443 // passthrough behaviour via plan as via broaden_presorted.
4444 let tab = synthetic_tab_resolution();
4445
4446 // n == 0: empty plan, empty result.
4447 let plan = tab.plan(&[]).unwrap();
4448 assert_eq!(plan.len(), 0);
4449 assert!(plan.is_empty());
4450 let out: Vec<f64> = plan.apply(&[]);
4451 assert!(out.is_empty());
4452
4453 // n == 1: passthrough for any spectrum value.
4454 let plan1 = tab.plan(&[5.0]).unwrap();
4455 assert_eq!(plan1.len(), 1);
4456 let out1 = plan1.apply(&[0.42]);
4457 assert_eq!(out1, vec![0.42]);
4458
4459 // e <= 0.0 in the middle of a grid: passthrough at that index.
4460 // Mixed positive / non-positive energies are pathological but
4461 // the current implementation handles them, and the plan must
4462 // match. Grid is still non-descending (0.0 ≤ 10.0 etc.) so
4463 // plan() accepts it.
4464 let energies = vec![1.0, 1.0, 10.0, 100.0];
4465 let spectrum = vec![0.1, 0.5, 0.9, 0.3];
4466 let via_plan = {
4467 let plan = tab.plan(&energies).unwrap();
4468 plan.apply(&spectrum)
4469 };
4470 let via_reference = broaden_presorted_reference(&tab, &energies, &spectrum);
4471 assert_bit_exact(
4472 &via_reference,
4473 &via_plan,
4474 "mixed_positive_and_zero_energies",
4475 );
4476 }
4477
4478 #[test]
4479 fn test_plan_rejects_unsorted_energies() {
4480 // `broaden()` rejects unsorted grids via validate_inputs; `plan()`
4481 // must do the same so a caller doesn't silently build a plan with
4482 // misbracketed e_prime lookups and then produce wrong σ output
4483 // from `ResolutionPlan::apply`.
4484 let tab = synthetic_tab_resolution();
4485 let result = tab.plan(&[10.0, 1.0, 100.0]);
4486 assert!(matches!(result, Err(ResolutionError::UnsortedEnergies)));
4487 }
4488
4489 #[test]
4490 fn test_plan_apply_is_nan_safe_at_degenerate_bracket() {
4491 // When two adjacent target energies are equal (span = 0), the
4492 // plan encodes `frac = 0.0` and the apply path must short-
4493 // circuit to `spectrum[lo]` without reading `spectrum[lo+1]`.
4494 // A NaN at the upper bracket would propagate through
4495 // `0.0 * NaN = NaN` and corrupt the result otherwise.
4496 let tab = synthetic_tab_resolution();
4497 // Grid has a degenerate duplicate at indices 1 and 2.
4498 let energies = vec![8.0, 10.0, 10.0, 12.0, 50.0, 100.0];
4499 // Spectrum with NaN exactly at the upper-bracket index (2) that
4500 // the degenerate pair maps to; any retained (target, kernel-
4501 // point) entry whose `e_prime` lands inside that duplicate
4502 // bracket MUST NOT pull the NaN into the output.
4503 let spectrum = vec![0.1, 0.5, f64::NAN, 0.9, 0.2, 0.05];
4504 let plan = tab.plan(&energies).unwrap();
4505 let via_plan = plan.apply(&spectrum);
4506 let via_reference = broaden_presorted_reference(&tab, &energies, &spectrum);
4507 // Both paths must agree on the non-pathological targets. The
4508 // reference path returns `spectrum[lo]` directly in the
4509 // degenerate case (no touch of `spectrum[lo+1]`) and the plan
4510 // path's `frac == 0.0` short-circuit matches bit-exactly.
4511 // Targets whose kernel legitimately interpolates across index 2
4512 // will pull the NaN in BOTH paths equally — that's physics, not
4513 // a bug — so we compare bit-pattern with a NaN-aware helper.
4514 assert_eq!(via_plan.len(), via_reference.len());
4515 for (i, (&a, &b)) in via_reference.iter().zip(via_plan.iter()).enumerate() {
4516 // Both NaN or both finite and bit-exact.
4517 if a.is_nan() {
4518 assert!(b.is_nan(), "plan[{i}]={b} but reference is NaN");
4519 } else {
4520 assert_eq!(
4521 a.to_bits(),
4522 b.to_bits(),
4523 "nan_safe mismatch at {i}: reference={a} plan={b}"
4524 );
4525 }
4526 }
4527 }
4528
4529 #[test]
4530 fn test_plan_apply_exact_match_frac_plus_zero_propagates_nan() {
4531 // Regression gate for a subtle sign-of-zero short-circuit bug.
4532 //
4533 // When `e_prime` aligns EXACTLY with a grid point `energies[lo]`,
4534 // `plan_presorted`'s interp fraction computes to `+0.0`, yet the
4535 // bracket is NOT degenerate (span is a normal positive float).
4536 // In that case `broaden_presorted` still evaluates
4537 // s = spectrum[lo] + (+0.0) * (spectrum[lo+1] - spectrum[lo])
4538 // which, for `spectrum[lo+1] = NaN`, reads `0.0 * NaN = NaN` and
4539 // propagates `NaN` into `s`. The earlier `frac == 0.0` branch
4540 // in `ResolutionPlan::apply` incorrectly treated this case as
4541 // degenerate (since `+0.0 == -0.0` under `==`) and short-circuited
4542 // to `spectrum[lo]`, producing a finite output where the scalar
4543 // reference produced NaN.
4544 //
4545 // Fix: `plan_presorted` now stores `-0.0` (negative-signed zero)
4546 // for the degenerate sentinel, and `apply` disambiguates via
4547 // `to_bits()`, so the non-degenerate `+0.0` path correctly reads
4548 // `spectrum[lo+1]` and propagates NaN.
4549 let tab = synthetic_tab_resolution();
4550
4551 // Grid has a point at energy = 10.0. We engineer a target grid
4552 // where the broadened kernel at one of the targets produces an
4553 // `e_prime` that aligns exactly with `energies[lo]` of one of its
4554 // retained entries. Achieved by building a coarse target grid
4555 // and letting the two-pointer walk land on an exact match on at
4556 // least one (target, kernel-point) pair.
4557 let energies: Vec<f64> = (0..32).map(|i| 1.0 + i as f64).collect();
4558 // Spectrum with NaN scattered at multiple lo+1 indices. At
4559 // least one retained plan entry in this synthetic configuration
4560 // will have `frac == +0.0` from an exact-match case, which must
4561 // propagate NaN in apply.
4562 let mut spectrum = vec![0.5_f64; energies.len()];
4563 for v in &mut spectrum[3..] {
4564 *v = f64::NAN;
4565 }
4566 let plan = tab.plan(&energies).unwrap();
4567 let via_plan = plan.apply(&spectrum);
4568 let via_reference = broaden_presorted_reference(&tab, &energies, &spectrum);
4569
4570 // Bit-exact equivalence on all targets, including NaN-propagated
4571 // ones. This test would FAIL pre-fix (plan returns finite where
4572 // reference returns NaN for any exact-match plan entry with a
4573 // NaN at `lo+1`).
4574 assert_eq!(via_plan.len(), via_reference.len());
4575 for (i, (&a, &b)) in via_reference.iter().zip(via_plan.iter()).enumerate() {
4576 if a.is_nan() {
4577 assert!(
4578 b.is_nan(),
4579 "target {i}: reference produced NaN (NaN propagated through \
4580 exact-match `frac = +0.0` path) but plan returned finite {b}",
4581 );
4582 } else {
4583 assert_eq!(
4584 a.to_bits(),
4585 b.to_bits(),
4586 "target {i}: reference={a} plan={b}"
4587 );
4588 }
4589 }
4590 }
4591
4592 #[test]
4593 #[should_panic(expected = "must match plan target-grid length")]
4594 fn test_plan_apply_spectrum_length_mismatch_panics() {
4595 let tab = synthetic_tab_resolution();
4596 let plan = tab.plan(&[1.0, 2.0, 3.0]).unwrap();
4597 // Wrong spectrum length — caller error should panic with a
4598 // clear message rather than silently producing garbage.
4599 let _ = plan.apply(&[0.1, 0.2]);
4600 }
4601
4602 // ─── apply_resolution_with_plan / _presorted_with_plan dispatch harness ───
4603 //
4604 // These gates cover the public/crate-visible wrappers added for
4605 // production plan-caching. Every production caller (fit-model
4606 // layer, spatial dispatch) goes through one of these two entries;
4607 // their dispatch choices must be byte-identical to the non-plan
4608 // paths they replace.
4609
4610 #[test]
4611 fn test_apply_resolution_with_plan_tabulated_matches_non_plan_path() {
4612 let tab = synthetic_tab_resolution();
4613 let resolution = ResolutionFunction::Tabulated(Arc::new(tab.clone()));
4614 let energies: Vec<f64> = (0..128).map(|i| 1.0 + i as f64 * (200.0 / 128.0)).collect();
4615 let spectrum: Vec<f64> = energies
4616 .iter()
4617 .map(|&e| 1.0 - 0.3 * (-((e - 20.0).powi(2) / 4.0)).exp())
4618 .collect();
4619
4620 let baseline = apply_resolution(&energies, &spectrum, &resolution).unwrap();
4621
4622 let plan = build_resolution_plan(&energies, &resolution).unwrap();
4623 assert!(
4624 plan.is_some(),
4625 "tabulated resolution must produce Some(plan)"
4626 );
4627 let planned =
4628 apply_resolution_with_plan(plan.as_ref(), &energies, &spectrum, &resolution).unwrap();
4629 assert_eq!(planned.len(), baseline.len());
4630 for (i, (&a, &b)) in baseline.iter().zip(planned.iter()).enumerate() {
4631 assert_eq!(
4632 a.to_bits(),
4633 b.to_bits(),
4634 "apply_resolution_with_plan mismatch at {i}: baseline={a} planned={b}"
4635 );
4636 }
4637 }
4638
4639 #[test]
4640 fn test_apply_resolution_with_plan_gaussian_returns_none_plan_and_matches() {
4641 let resolution =
4642 ResolutionFunction::Gaussian(ResolutionParams::new(25.0, 1.0e-3, 0.02, 0.01).unwrap());
4643 let energies: Vec<f64> = (0..64).map(|i| 1.0 + i as f64 * 3.0).collect();
4644 let spectrum: Vec<f64> = energies.iter().map(|&e| 1.0 / e).collect();
4645
4646 let plan = build_resolution_plan(&energies, &resolution).unwrap();
4647 assert!(
4648 plan.is_none(),
4649 "Gaussian resolution must not produce a plan"
4650 );
4651
4652 let baseline = apply_resolution(&energies, &spectrum, &resolution).unwrap();
4653 let planned =
4654 apply_resolution_with_plan(plan.as_ref(), &energies, &spectrum, &resolution).unwrap();
4655 assert_eq!(planned.len(), baseline.len());
4656 for (i, (&a, &b)) in baseline.iter().zip(planned.iter()).enumerate() {
4657 assert_eq!(
4658 a.to_bits(),
4659 b.to_bits(),
4660 "gaussian fallback mismatch at {i}: baseline={a} planned={b}"
4661 );
4662 }
4663 }
4664
4665 #[test]
4666 fn test_apply_resolution_with_plan_rejects_same_length_different_grid() {
4667 // `p.len() == energies.len()` is necessary
4668 // but not sufficient. A plan built for one grid and applied
4669 // to a different same-length grid would silently gather
4670 // spectrum values at brackets belonging to the original grid
4671 // — wrong σ output without any error surfaced. The grid-
4672 // identity check in `apply_resolution_with_plan` guards this
4673 // failure mode and reports the first differing index.
4674 let tab = synthetic_tab_resolution();
4675 let resolution = ResolutionFunction::Tabulated(Arc::new(tab.clone()));
4676 let energies_plan: Vec<f64> = (0..32).map(|i| 1.0 + i as f64).collect();
4677 let mut energies_apply = energies_plan.clone();
4678 // Perturb a single interior point so lengths still match.
4679 energies_apply[5] += 0.25;
4680 let spectrum = vec![0.7; energies_apply.len()];
4681
4682 let plan = tab.plan(&energies_plan).unwrap();
4683 let result =
4684 apply_resolution_with_plan(Some(&plan), &energies_apply, &spectrum, &resolution);
4685 match result {
4686 Err(ResolutionError::PlanGridMismatch { first_diff_index }) => {
4687 assert_eq!(first_diff_index, 5);
4688 }
4689 other => panic!("expected PlanGridMismatch, got {:?}", other),
4690 }
4691 }
4692
4693 #[test]
4694 fn test_apply_resolution_with_plan_rejects_length_mismatch() {
4695 let tab = synthetic_tab_resolution();
4696 let resolution = ResolutionFunction::Tabulated(Arc::new(tab.clone()));
4697 let energies_plan: Vec<f64> = (0..32).map(|i| 1.0 + i as f64).collect();
4698 let energies_apply: Vec<f64> = (0..48).map(|i| 1.0 + i as f64).collect();
4699 let spectrum = vec![0.5; energies_apply.len()];
4700
4701 let plan = tab.plan(&energies_plan).unwrap();
4702 let result =
4703 apply_resolution_with_plan(Some(&plan), &energies_apply, &spectrum, &resolution);
4704 match result {
4705 Err(ResolutionError::LengthMismatch { energies, data }) => {
4706 assert_eq!(energies, 48);
4707 assert_eq!(data, 32);
4708 }
4709 other => panic!("expected LengthMismatch, got {:?}", other),
4710 }
4711 }
4712
4713 #[test]
4714 fn test_build_resolution_plan_rejects_unsorted_energies_for_gaussian() {
4715 // Gaussian returns None on success, but must still reject an
4716 // unsorted grid — callers use `build_resolution_plan` to
4717 // centralise the sort-check so the downstream `apply` path can
4718 // skip it.
4719 let resolution =
4720 ResolutionFunction::Gaussian(ResolutionParams::new(25.0, 1.0e-3, 0.02, 0.01).unwrap());
4721 let result = build_resolution_plan(&[3.0, 1.0, 2.0], &resolution);
4722 assert!(matches!(result, Err(ResolutionError::UnsortedEnergies)));
4723 }
4724
4725 // ---------------------------------------------------------------
4726 // `crates/nereids-physics/tests/venus_usr_resolution_microbench.rs`
4727 // (`test_broaden_presorted_bench`, `test_plan_reuse_bench`,
4728 // `resolution_matrix_apply_microbench`) — see. They
4729 // parse a synthetic SAMMY USR-format kernel via
4730 // `common::synthetic_venus_usr_tab()` (the real VENUS BL10
4731 // fixture is not approved for public release; ).
4732 // ---------------------------------------------------------------
4733
4734 // ---------- ResolutionMatrix (CSR compile) tests ----------
4735 //
4736 // CI-hermetic synthetic tests — use hand-constructed
4737 // `ResolutionPlan`s via `make_synthetic_plan`; no fixture
4738 // dependency, run on every `cargo test` invocation. Cover
4739 // passthrough rows, `-0.0` sentinel rows, regular linear-interp
4740 // rows, CSR invariants, and the non-finite contract exclusion.
4741 //
4742 // End-to-end equivalence tests against the VENUS-like USR
4743 // operator (synthetic SAMMY-format kernel) at realistic grid
4744 // sizes (512, 3471) live in
4745 // `crates/nereids-physics/tests/venus_usr_resolution.rs` — see
4746 // issues and.
4747
4748 /// Hybrid abs+rel tolerance used across equivalence tests. Guards
4749 /// against the `a ≈ 0` trap where `a.abs().max(1e-300)` produces
4750 /// meaningless relative errors for genuinely-zero reference values.
4751 fn max_hybrid_err(a: &[f64], b: &[f64]) -> f64 {
4752 a.iter()
4753 .zip(b)
4754 .map(|(x, y)| {
4755 let denom = x.abs().max(y.abs()).max(1e-12);
4756 (x - y).abs() / denom
4757 })
4758 .fold(0.0_f64, f64::max)
4759 }
4760
4761 /// Build a synthetic multi-row plan with realistic overlap
4762 /// patterns — used as a CI-hermetic stand-in for the VENUS
4763 /// kernel. Each target row `i` draws weights from a triangular
4764 /// kernel around column `i`, normalized so the row is
4765 /// row-stochastic. `half_kernel` controls the spread.
4766 fn make_synthetic_overlap_plan(n_grid: usize, half_kernel: usize) -> ResolutionPlan {
4767 assert!(n_grid > 2 * half_kernel, "grid too small for kernel");
4768 let energies: Vec<f64> = (0..n_grid).map(|i| 10.0 + i as f64).collect();
4769 let mut rows: Vec<SyntheticRow> = Vec::with_capacity(n_grid);
4770 for i in 0..n_grid {
4771 let lo_min = i.saturating_sub(half_kernel);
4772 // Clamp so `lo ∈ [0, n_grid - 2]` — the linear-interp
4773 // branch reads `spec[lo + 1]`, and the `-0.0` sentinel is
4774 // the only way to safely go up to `lo = n_grid - 1`. We
4775 // keep all synthetic entries on the regular branch here.
4776 let lo_max = (i + half_kernel).min(n_grid - 2);
4777 let entries: Vec<SyntheticEntry> = (lo_min..=lo_max)
4778 .map(|lo| {
4779 let d = (lo as i64 - i as i64).abs() as f64;
4780 let w = 1.0 - d / (half_kernel as f64 + 1.0);
4781 // A uniform `frac = 0.5` distributes each entry's
4782 // weight evenly across `lo` and `lo + 1`, which
4783 // exercises the regular linear-interp branch of
4784 // `compile_to_matrix`.
4785 SyntheticEntry {
4786 lo: lo as u32,
4787 frac: 0.5,
4788 weight: w,
4789 }
4790 })
4791 .collect();
4792 let norm: f64 = entries.iter().map(|e| e.weight).sum();
4793 rows.push(SyntheticRow { entries, norm });
4794 }
4795 make_synthetic_plan(energies, rows)
4796 }
4797
4798 /// CI-hermetic: row-stochasticity on a synthetic multi-row plan.
4799 #[test]
4800 fn resolution_matrix_is_row_stochastic_synthetic() {
4801 let plan = make_synthetic_overlap_plan(40, 5);
4802 let matrix = plan.compile_to_matrix();
4803 for i in 0..matrix.len() {
4804 let start = matrix.row_starts()[i] as usize;
4805 let end = matrix.row_starts()[i + 1] as usize;
4806 let row_sum: f64 = matrix.values()[start..end].iter().sum();
4807 assert!(
4808 (row_sum - 1.0).abs() < 1e-13,
4809 "row {} sum = {} (expected 1.0 within 1e-13)",
4810 i,
4811 row_sum,
4812 );
4813 }
4814 }
4815
4816 /// CI-hermetic: equivalence of `apply_r` and `plan.apply` on a
4817 /// synthetic multi-row plan, 40-point grid, half-kernel 5.
4818 #[test]
4819 fn resolution_matrix_apply_equivalent_to_plan_apply_synthetic() {
4820 let plan = make_synthetic_overlap_plan(40, 5);
4821 let matrix = plan.compile_to_matrix();
4822 // Beer-Lambert-shaped synthetic spectrum, bounded [0, 1].
4823 let spec: Vec<f64> = (0..matrix.len())
4824 .map(|i| {
4825 let x = i as f64 / 39.0;
4826 1.0 - 0.7 * (-((x - 0.5).powi(2)) / 0.01).exp()
4827 })
4828 .collect();
4829 let plan_out = plan.apply(&spec);
4830 let matrix_out = apply_r(&matrix, &spec);
4831 let max_err = max_hybrid_err(&plan_out, &matrix_out);
4832 assert!(
4833 max_err < 1e-12,
4834 "synthetic apply_r vs plan.apply max hybrid err = {:.3e} (expected < 1e-12)",
4835 max_err,
4836 );
4837 }
4838
4839 /// CI-hermetic: CSR column indices strictly ascending per row on
4840 /// a synthetic multi-row plan.
4841 #[test]
4842 fn resolution_matrix_csr_column_indices_sorted_per_row_synthetic() {
4843 let plan = make_synthetic_overlap_plan(30, 4);
4844 let matrix = plan.compile_to_matrix();
4845 for i in 0..matrix.len() {
4846 let start = matrix.row_starts()[i] as usize;
4847 let end = matrix.row_starts()[i + 1] as usize;
4848 let row_cols = &matrix.col_indices()[start..end];
4849 for w in row_cols.windows(2) {
4850 assert!(
4851 w[0] < w[1],
4852 "row {} col_indices not strictly ascending: {:?}",
4853 i,
4854 row_cols,
4855 );
4856 }
4857 }
4858 }
4859
4860 /// CI-hermetic: grid-mismatch / length-mismatch detection via
4861 /// `apply_resolution_with_matrix` on a synthetic plan.
4862 #[test]
4863 fn resolution_matrix_grid_and_length_mismatch_synthetic() {
4864 let plan = make_synthetic_overlap_plan(16, 3);
4865 let matrix = plan.compile_to_matrix();
4866 let n = matrix.len();
4867 let energies: Vec<f64> = (0..n).map(|i| 10.0 + i as f64).collect();
4868 let spec = vec![1.0_f64; n];
4869
4870 // Same grid + length → passes.
4871 assert!(apply_resolution_with_matrix(&energies, &matrix, &spec).is_ok());
4872
4873 // Perturb one energy → MatrixGridMismatch with offending
4874 // index.
4875 let mut mutated = energies.clone();
4876 mutated[7] += 1e-12;
4877 let err = apply_resolution_with_matrix(&mutated, &matrix, &spec)
4878 .expect_err("grid mismatch must error");
4879 assert_eq!(
4880 err,
4881 ResolutionError::MatrixGridMismatch {
4882 first_diff_index: 7,
4883 }
4884 );
4885
4886 // Short spectrum → LengthMismatch.
4887 let short = vec![1.0_f64; n - 1];
4888 let err = apply_resolution_with_matrix(&energies, &matrix, &short)
4889 .expect_err("length mismatch must error");
4890 assert!(matches!(err, ResolutionError::LengthMismatch { .. }));
4891 }
4892
4893 // ---------------------------------------------------------------
4894 // `crates/nereids-physics/tests/venus_usr_resolution.rs`
4895 // (`resolution_matrix_is_row_stochastic_on_venus_kernel`,
4896 // `resolution_matrix_apply_equivalent_to_plan_apply_on_venus_kernel`,
4897 // `resolution_matrix_apply_equivalent_at_production_grid`,
4898 // `resolution_matrix_apply_equivalent_across_densities`,
4899 // `resolution_matrix_csr_column_indices_sorted_per_row`,
4900 // `resolution_matrix_grid_mismatch_detected`,
4901 // `resolution_matrix_length_mismatch_detected`) — see issues
4902 // and. They parse a synthetic SAMMY USR-format kernel
4903 // via `common::synthetic_venus_usr_tab()`.
4904 // ---------------------------------------------------------------
4905
4906 #[test]
4907 fn resolution_matrix_empty_plan() {
4908 // Compile must not panic and must produce a valid empty
4909 // matrix when the plan itself is empty. Build the empty
4910 // plan synthetically (no fixture needed) — an empty
4911 // `target_energies` plus empty `norm` / `starts = [0]`
4912 // yields the same zero-row plan that
4913 // `TabulatedResolution::plan(&[])` would produce.
4914 let plan = make_synthetic_plan(Vec::new(), Vec::new());
4915 let matrix = plan.compile_to_matrix();
4916 assert_eq!(matrix.len(), 0);
4917 assert!(matrix.is_empty());
4918 assert_eq!(matrix.nnz(), 0);
4919 }
4920
4921 /// Hand-construct a `ResolutionPlan` that deliberately exercises
4922 /// both the passthrough branch (`norm ≤ DIVISION_FLOOR`) and the
4923 /// `-0.0` degenerate-bracket sentinel — neither of which is
4924 /// reached on the VENUS fixture at the tested grid sizes, which
4925 /// made the earlier fixture-based passthrough test vacuous. This
4926 /// replacement verifies the two unreached branches with direct
4927 /// assertions on the resulting CSR.
4928 fn make_synthetic_plan(target_energies: Vec<f64>, rows: Vec<SyntheticRow>) -> ResolutionPlan {
4929 let n = target_energies.len();
4930 assert_eq!(rows.len(), n);
4931 let mut starts: Vec<u32> = Vec::with_capacity(n + 1);
4932 starts.push(0);
4933 let mut lo_idx: Vec<u32> = Vec::new();
4934 let mut frac: Vec<f64> = Vec::new();
4935 let mut weight: Vec<f64> = Vec::new();
4936 let mut norm: Vec<f64> = Vec::with_capacity(n);
4937 for row in &rows {
4938 norm.push(row.norm);
4939 for entry in &row.entries {
4940 lo_idx.push(entry.lo);
4941 frac.push(entry.frac);
4942 weight.push(entry.weight);
4943 }
4944 starts.push(lo_idx.len() as u32);
4945 }
4946 ResolutionPlan {
4947 target_energies,
4948 starts,
4949 lo_idx,
4950 frac,
4951 weight,
4952 norm,
4953 }
4954 }
4955
4956 struct SyntheticRow {
4957 entries: Vec<SyntheticEntry>,
4958 norm: f64,
4959 }
4960
4961 struct SyntheticEntry {
4962 lo: u32,
4963 frac: f64,
4964 weight: f64,
4965 }
4966
4967 #[test]
4968 fn resolution_matrix_passthrough_row_compiles_to_identity_entry() {
4969 // Row 0: passthrough via norm ≤ DIVISION_FLOOR.
4970 // Row 1: regular linear-interp entry (lo=1 → reads cols 1, 2).
4971 // Row 2: degenerate `-0.0` sentinel entry (lo=2 → reads col 2 only).
4972 //
4973 // Grid has 4 cells so `lo ∈ [0, n-2] = [0, 2]` holds for all
4974 // entries — this preserves the `ResolutionPlan::apply` SAFETY
4975 // invariant that `lo + 1 < n` even if a future refactor
4976 // weakens the `-0.0` sentinel short-circuit.
4977 let plan = make_synthetic_plan(
4978 vec![10.0, 20.0, 30.0, 40.0],
4979 vec![
4980 SyntheticRow {
4981 entries: vec![],
4982 // 0.0 is <= DIVISION_FLOOR, so row 0 goes through
4983 // the passthrough branch.
4984 norm: 0.0,
4985 },
4986 SyntheticRow {
4987 entries: vec![SyntheticEntry {
4988 lo: 1,
4989 frac: 0.25,
4990 weight: 1.0,
4991 }],
4992 norm: 1.0,
4993 },
4994 SyntheticRow {
4995 entries: vec![SyntheticEntry {
4996 lo: 2,
4997 frac: -0.0,
4998 weight: 1.0,
4999 }],
5000 norm: 1.0,
5001 },
5002 // Row 3: passthrough too, to round out the 4-cell grid.
5003 SyntheticRow {
5004 entries: vec![],
5005 norm: 0.0,
5006 },
5007 ],
5008 );
5009 let matrix = plan.compile_to_matrix();
5010
5011 // Row 0 — single (0, 0, 1.0).
5012 let r0_start = matrix.row_starts()[0] as usize;
5013 let r0_end = matrix.row_starts()[1] as usize;
5014 assert_eq!(r0_end - r0_start, 1, "passthrough row must have 1 entry");
5015 assert_eq!(matrix.col_indices()[r0_start], 0);
5016 assert_eq!(matrix.values()[r0_start].to_bits(), 1.0_f64.to_bits());
5017
5018 // Row 1 — linear-interp: contributes at col 1 and col 2.
5019 let r1_start = matrix.row_starts()[1] as usize;
5020 let r1_end = matrix.row_starts()[2] as usize;
5021 assert_eq!(
5022 r1_end - r1_start,
5023 2,
5024 "linear-interp row must have 2 entries"
5025 );
5026 assert_eq!(matrix.col_indices()[r1_start], 1);
5027 assert_eq!(matrix.col_indices()[r1_start + 1], 2);
5028 assert!((matrix.values()[r1_start] - 0.75).abs() < 1e-14);
5029 assert!((matrix.values()[r1_start + 1] - 0.25).abs() < 1e-14);
5030
5031 // Row 2 — `-0.0` sentinel: single entry at col 2 (no col 3).
5032 let r2_start = matrix.row_starts()[2] as usize;
5033 let r2_end = matrix.row_starts()[3] as usize;
5034 assert_eq!(
5035 r2_end - r2_start,
5036 1,
5037 "-0.0 sentinel row must have exactly 1 entry (not 2)",
5038 );
5039 assert_eq!(matrix.col_indices()[r2_start], 2);
5040 assert_eq!(matrix.values()[r2_start].to_bits(), 1.0_f64.to_bits());
5041
5042 // Cross-check with apply semantics: spec[3] is chosen so the
5043 // sentinel row, if buggy, would contaminate the output.
5044 // Both `plan.apply` and `apply_r` must ignore spec[3] at
5045 // row 2.
5046 let spec = vec![7.0, 11.0, 13.0, 999.0];
5047 let plan_out = plan.apply(&spec);
5048 let matrix_out = apply_r(&matrix, &spec);
5049 // Row 0 passthrough: out[0] = spec[0] = 7.
5050 assert!((matrix_out[0] - 7.0).abs() < 1e-14);
5051 assert!((plan_out[0] - 7.0).abs() < 1e-14);
5052 // Row 1: 0.75 * spec[1] + 0.25 * spec[2] = 0.75*11 + 0.25*13 = 11.5.
5053 assert!((matrix_out[1] - 11.5).abs() < 1e-14);
5054 assert!((plan_out[1] - 11.5).abs() < 1e-14);
5055 // Row 2 sentinel: 1.0 * spec[2] = 13 — NOT 999 (would indicate
5056 // spec[lo+1] was read).
5057 assert!((matrix_out[2] - 13.0).abs() < 1e-14);
5058 assert!((plan_out[2] - 13.0).abs() < 1e-14);
5059 // Row 3 passthrough: out[3] = spec[3] = 999.
5060 assert!((matrix_out[3] - 999.0).abs() < 1e-14);
5061 assert!((plan_out[3] - 999.0).abs() < 1e-14);
5062 }
5063
5064 /// Documents (and guards) the explicit contract exclusion on
5065 /// non-finite spectra between `ResolutionPlan::apply` and
5066 /// `apply_r`. See [`ResolutionPlan::compile_to_matrix`] docstring
5067 /// for the full reasoning; this test simply pins the divergence
5068 /// so a future unification attempt fails loudly.
5069 #[test]
5070 fn resolution_matrix_nonfinite_contract() {
5071 // 3-cell grid so `lo = 0` for the regular row reads cols 0, 1
5072 // and the sentinel row at `lo = 1` reads col 1 only — `lo ∈
5073 // [0, n-2] = [0, 1]` satisfied.
5074 let plan = make_synthetic_plan(
5075 vec![10.0, 20.0, 30.0],
5076 vec![
5077 SyntheticRow {
5078 entries: vec![SyntheticEntry {
5079 lo: 0,
5080 frac: 0.5,
5081 weight: 1.0,
5082 }],
5083 norm: 1.0,
5084 },
5085 SyntheticRow {
5086 entries: vec![SyntheticEntry {
5087 lo: 1,
5088 frac: -0.0, // sentinel: short-circuit to spec[lo]
5089 weight: 1.0,
5090 }],
5091 norm: 1.0,
5092 },
5093 SyntheticRow {
5094 entries: vec![],
5095 norm: 0.0, // passthrough
5096 },
5097 ],
5098 );
5099 let matrix = plan.compile_to_matrix();
5100
5101 // Spectrum with same-sign infinities in both bins of row 0's
5102 // non-degenerate bracket.
5103 let inf_spec = vec![f64::INFINITY, f64::INFINITY, 0.0];
5104 let plan_out = plan.apply(&inf_spec);
5105 let matrix_out = apply_r(&matrix, &inf_spec);
5106
5107 // Row 0: plan.apply evaluates `s_lo + frac * (s_hi - s_lo)`
5108 // = `+∞ + 0.5 * (+∞ - +∞)` = `+∞ + 0.5 * NaN` = NaN.
5109 // apply_r evaluates `0.5 * +∞ + 0.5 * +∞` = `+∞`.
5110 assert!(plan_out[0].is_nan(), "plan.apply must produce NaN on ∞+∞");
5111 assert!(matrix_out[0].is_infinite(), "apply_r collapses ∞+∞ to ∞");
5112
5113 // Row 1 (sentinel): both paths short-circuit to spec[lo] = ∞,
5114 // so there is no divergence here.
5115 assert!(plan_out[1].is_infinite());
5116 assert!(matrix_out[1].is_infinite());
5117 }
5118
5119 /// Documents (and guards) the analogous
5120 /// divergence on **finite spectra near f64 overflow**. With
5121 /// opposite-sign neighboring bins at f64::MAX, `plan.apply`'s
5122 /// `s_lo + frac * (s_hi - s_lo)` overflows in the subtraction
5123 /// and returns `±∞`, while `apply_r`'s `(1 - frac) * s_lo +
5124 /// frac * s_hi` stays finite because the overflow is avoided by
5125 /// scaling before summation. This is why the equivalence
5126 /// contract on [`ResolutionPlan::compile_to_matrix`] is scoped
5127 /// to bounded finite spectra (Beer-Lambert `T ∈ [0, 1]`) — no
5128 /// production forward model can hit this case.
5129 #[test]
5130 fn resolution_matrix_large_finite_contract() {
5131 let plan = make_synthetic_plan(
5132 vec![10.0, 20.0, 30.0],
5133 vec![
5134 SyntheticRow {
5135 entries: vec![SyntheticEntry {
5136 lo: 0,
5137 frac: 0.5,
5138 weight: 1.0,
5139 }],
5140 norm: 1.0,
5141 },
5142 SyntheticRow {
5143 entries: vec![],
5144 norm: 0.0, // passthrough
5145 },
5146 SyntheticRow {
5147 entries: vec![],
5148 norm: 0.0,
5149 },
5150 ],
5151 );
5152 let matrix = plan.compile_to_matrix();
5153
5154 // Opposite-sign large finite bins at row 0's non-degenerate
5155 // bracket. `s_hi - s_lo = -f64::MAX - f64::MAX = -∞`.
5156 let big_spec = vec![f64::MAX, -f64::MAX, 0.0];
5157 let plan_out = plan.apply(&big_spec);
5158 let matrix_out = apply_r(&matrix, &big_spec);
5159
5160 // plan.apply: s_lo + frac * (s_hi - s_lo) = MAX + 0.5 * (-∞)
5161 // = MAX + -∞ = -∞.
5162 assert!(
5163 plan_out[0].is_infinite() && plan_out[0] < 0.0,
5164 "plan.apply must overflow to -∞ on opposite-sign MAX bins; got {}",
5165 plan_out[0],
5166 );
5167 // apply_r: 0.5 * MAX + 0.5 * -MAX = 0.
5168 assert!(
5169 matrix_out[0].is_finite(),
5170 "apply_r must stay finite (scaled before summation); got {}",
5171 matrix_out[0],
5172 );
5173 assert!(matrix_out[0].abs() < 1e-280);
5174 }
5175
5176 // ------------------------------------------------------------------
5177 // TabulatedResolution::kernel_support_ev — used by SAMMY EMIN/EMAX
5178 // -equivalent fit-energy-range margin computation.
5179 // ------------------------------------------------------------------
5180
5181 /// `synthetic_tab_resolution` uses triangular kernels whose
5182 /// outermost entries (`weight = 1 - |dt|/half`) are exactly zero
5183 /// at `dt = ±half`; the actual non-zero support is the next-
5184 /// outermost entry at `±half · (1 - 1/(n-1))`.
5185 fn triangle_dt_max(half: f64, n: usize) -> f64 {
5186 // dt_step = 2*half / (n-1); next-outermost = half - dt_step
5187 half - 2.0 * half / (n - 1) as f64
5188 }
5189
5190 /// Exact-map expected support: the larger of the up-side excursion
5191 /// `E·((t/(t−dt⁺))²−1)` and the down-side `E·(1−(t/(t+dt⁻))²)`
5192 /// with `t = TOF_FACTOR·L/√E` — the same map the broadener applies
5193 /// per kernel point, so this oracle is exact by construction.
5194 fn exact_support(e: f64, dt_pos: f64, dt_neg: f64, l: f64) -> f64 {
5195 let t = TOF_FACTOR * l / e.sqrt();
5196 let up = e * ((t / (t - dt_pos)).powi(2) - 1.0);
5197 let down = e * (1.0 - (t / (t + dt_neg)).powi(2));
5198 up.max(down)
5199 }
5200
5201 /// At a reference energy with a known kernel half-width in TOF, the
5202 /// support must equal the exact TOF→E excursion of the outermost
5203 /// non-zero offsets. At E = 50 eV the triangle kernel has
5204 /// half = 1.0 μs, n = 41, so the largest non-zero offset is
5205 /// `1.0 · (1 − 1/40) = 0.975` on both sides.
5206 #[test]
5207 fn test_tabulated_kernel_support_at_ref_energy_matches_exact_map() {
5208 let r = synthetic_tab_resolution();
5209 let e: f64 = 50.0;
5210 let dt_max = triangle_dt_max(1.0, 41);
5211 let expected = exact_support(e, dt_max, dt_max, 25.0);
5212 let got = r.kernel_support_ev(e);
5213 assert!(
5214 (got - expected).abs() / expected < 1e-12,
5215 "support at ref energy: got {got}, expected {expected}"
5216 );
5217 }
5218
5219 /// Between two reference energies the support tracks the ACTUAL
5220 /// width-interpolated kernel: it must cover that kernel's non-zero
5221 /// offsets (non-circular — the blend comes from
5222 /// `interpolated_kernel` itself), while sitting strictly BELOW the
5223 /// old take-the-wider-bracket bound (proving the interior arm
5224 /// engaged rather than falling back to per-kernel extremes).
5225 #[test]
5226 fn test_tabulated_kernel_support_covers_actual_blend_between_refs() {
5227 let r = synthetic_tab_resolution();
5228 let e: f64 = 100.0; // between the 50 eV and 500 eV references
5229 let got = r.kernel_support_ev(e);
5230
5231 // Cover: exact excursion of the blended kernel's w>0 extremes.
5232 let (offs, ws) = test_support::interpolated_kernel(&r, e);
5233 let dt_pos = offs
5234 .iter()
5235 .zip(&ws)
5236 .filter(|&(_, &w)| w > 0.0)
5237 .map(|(&o, _)| o)
5238 .fold(0.0f64, f64::max);
5239 let dt_neg = offs
5240 .iter()
5241 .zip(&ws)
5242 .filter(|&(_, &w)| w > 0.0)
5243 .map(|(&o, _)| -o)
5244 .fold(0.0f64, f64::max);
5245 let actual_excursion = exact_support(e, dt_pos, dt_neg, 25.0);
5246 assert!(
5247 got >= actual_excursion * (1.0 - 1e-12),
5248 "support must cover the actual blended kernel: got {got}, \
5249 actual excursion {actual_excursion}"
5250 );
5251
5252 // Tightness + non-vacuity: strictly below the pre-blend bound
5253 // built from the wider 500 eV bracket's extreme (1.96 µs) —
5254 // the between-ref kernel is genuinely narrower.
5255 let old_bound = exact_support(e, triangle_dt_max(2.0, 51), triangle_dt_max(2.0, 51), 25.0);
5256 assert!(
5257 got < old_bound,
5258 "interior support must track the narrower interpolated \
5259 kernel: got {got}, old wider-bracket bound {old_bound}"
5260 );
5261 }
5262
5263 /// Below the lowest ref energy: use the lowest ref kernel.
5264 /// Above the highest ref energy: use the highest ref kernel.
5265 #[test]
5266 fn test_tabulated_kernel_support_uses_nearest_outside_grid() {
5267 let r = synthetic_tab_resolution();
5268 // Below grid (ref_min = 5 eV; triangle(half=0.5, n=31)).
5269 let e_low: f64 = 1.0;
5270 let dt_low = triangle_dt_max(0.5, 31);
5271 let exp_low = exact_support(e_low, dt_low, dt_low, 25.0);
5272 assert!((r.kernel_support_ev(e_low) - exp_low).abs() / exp_low < 1e-12);
5273 // Above grid (ref_max = 500 eV; triangle(half=2.0, n=51)).
5274 let e_hi: f64 = 1000.0;
5275 let dt_hi = triangle_dt_max(2.0, 51);
5276 let exp_hi = exact_support(e_hi, dt_hi, dt_hi, 25.0);
5277 assert!((r.kernel_support_ev(e_hi) - exp_hi).abs() / exp_hi < 1e-12);
5278 }
5279
5280 /// The exact up-side excursion strictly exceeds the linear
5281 /// chain-rule estimate for a wide positive (delayed-emission) tail
5282 /// at high energy — the case where the old linear margin
5283 /// under-covered exactly the side the convolution gather loads.
5284 #[test]
5285 fn test_tabulated_kernel_support_exceeds_linear_estimate_for_wide_tail() {
5286 let offsets = vec![-1.0, 0.0, 15.0];
5287 let weights = vec![0.3, 1.0, 0.2];
5288 let r = TabulatedResolution {
5289 ref_energies: Arc::new(vec![100.0]),
5290 kernels: Arc::new(vec![(offsets, weights)]),
5291 flight_path_m: 25.0,
5292 };
5293 let e: f64 = 100.0;
5294 let linear = 2.0 * e.powf(1.5) / (TOF_FACTOR * 25.0) * 15.0;
5295 let got = r.kernel_support_ev(e);
5296 assert!(
5297 got > linear,
5298 "exact support must exceed the linear estimate on the \
5299 high-E side: got {got}, linear {linear}"
5300 );
5301 let expected = exact_support(e, 15.0, 1.0, 25.0);
5302 assert!(
5303 (got - expected).abs() / expected < 1e-12,
5304 "exact support: got {got}, expected {expected}"
5305 );
5306 }
5307
5308 /// An offset at or past the nominal flight time contributes no reach: the
5309 /// support is that of the surviving points.
5310 #[test]
5311 fn test_tabulated_kernel_support_counts_only_points_the_broadening_keeps() {
5312 let offsets = vec![0.0, 2.0, 10.0];
5313 let weights = vec![1.0, 0.5, 0.5];
5314 let r = TabulatedResolution {
5315 ref_energies: Arc::new(vec![100.0]),
5316 kernels: Arc::new(vec![(offsets, weights)]),
5317 flight_path_m: 25.0,
5318 };
5319 // Choose E high enough that 2 μs < t = K·L/√E ≤ 10 μs, so the
5320 // 10 μs point is dropped and the 2 μs point is the last kept.
5321 let t_at = |e: f64| TOF_FACTOR * 25.0 / e.sqrt();
5322 let mut e: f64 = 100.0;
5323 while t_at(e) > 10.0 {
5324 e *= 10.0;
5325 }
5326 let t = t_at(e);
5327 assert!(t > 2.0, "fixture must keep the 2 μs point (t = {t})");
5328 let expected_above = e * ((t / (t - 2.0)).powi(2) - 1.0);
5329 let (_, high) = r.gather_bounds_ev(e);
5330 let above = high - e;
5331 assert!(above.is_finite(), "reach must be finite, got {above}");
5332 assert!(
5333 (above - expected_above).abs() <= 1e-9 * expected_above,
5334 "reach {above} should be that of the last surviving offset, {expected_above}"
5335 );
5336 }
5337
5338 /// Between two references an offset past the flight time drops only
5339 /// itself: the reach is that of the last surviving offset of the blended
5340 /// kernel.
5341 #[test]
5342 fn test_tabulated_kernel_support_between_refs_keeps_surviving_offsets() {
5343 let offsets = vec![0.0, 10.0, 20.0, 80.0];
5344 let weights = vec![1.0; 4];
5345 let r = TabulatedResolution::from_kernels(
5346 vec![1.0, 4.0],
5347 vec![(offsets.clone(), weights.clone()), (offsets, weights)],
5348 1.0,
5349 )
5350 .expect("valid two-block table");
5351 let e: f64 = 2.0;
5352 let t = TOF_FACTOR / e.sqrt();
5353 assert!(
5354 t > 20.0 && t < 80.0,
5355 "fixture must drop the 80 μs point and keep the 20 μs point (t = {t})"
5356 );
5357 let expected_above = e * ((t / (t - 20.0)).powi(2) - 1.0);
5358 let (_, high) = r.gather_bounds_ev(e);
5359 let above = high - e;
5360 assert!(
5361 (above - expected_above).abs() <= 1e-9 * expected_above,
5362 "reach {above} should be that of the last surviving offset, {expected_above}"
5363 );
5364 }
5365
5366 /// Non-positive / non-finite energy → 0.0 (no broadening footprint).
5367 #[test]
5368 fn test_tabulated_kernel_support_returns_zero_for_invalid_energy() {
5369 let r = synthetic_tab_resolution();
5370 assert_eq!(r.kernel_support_ev(0.0), 0.0);
5371 assert_eq!(r.kernel_support_ev(-1.0), 0.0);
5372 assert_eq!(r.kernel_support_ev(f64::NAN), 0.0);
5373 assert_eq!(r.kernel_support_ev(f64::INFINITY), 0.0);
5374 }
5375
5376 /// Zero-weight tail entries must not inflate the support; only
5377 /// `weights[i] > 0` entries count.
5378 #[test]
5379 fn test_tabulated_kernel_support_ignores_zero_weight_entries() {
5380 // Build a kernel where the outermost entries have weight 0.
5381 let offsets = vec![-10.0, -1.0, 0.0, 1.0, 10.0];
5382 let weights = vec![0.0, 0.5, 1.0, 0.5, 0.0];
5383 let r = TabulatedResolution {
5384 ref_energies: Arc::new(vec![100.0]),
5385 kernels: Arc::new(vec![(offsets, weights)]),
5386 flight_path_m: 25.0,
5387 };
5388 let e: f64 = 100.0;
5389 // Expected support uses dt = ±1.0 (the outermost zero-weight
5390 // entries at ±10 are ignored), not ±10.0.
5391 let expected = exact_support(e, 1.0, 1.0, 25.0);
5392 let got = r.kernel_support_ev(e);
5393 assert!(
5394 (got - expected).abs() / expected < 1e-12,
5395 "support should ignore zero-weight entries: got {got}, expected {expected}"
5396 );
5397 }
5398
5399 /// Between reference kernels, the blended shape is positive on the
5400 /// FRINGE between a block's outermost `w > 0` entry and its
5401 /// adjacent `w == 0` entry (linear interpolation), and a merged
5402 /// point from the other block can land there — so the support's
5403 /// closure extremes (outermost positive weight extended to the
5404 /// adjacent zero-weight entry) must cover the actual blended
5405 /// kernel, which reaches beyond both blocks' bare `w > 0` maxima.
5406 #[test]
5407 fn test_tabulated_kernel_support_covers_blend_activated_offsets() {
5408 // Kernel A's positive support ends at z ≈ 3.5 (offset 5,
5409 // σ_A ≈ 1.44) with a zero-weight fringe out to z ≈ 7.0
5410 // (offset 10). Kernel B is compact (σ_B ≈ 0.97) with a
5411 // low-weight point at z ≈ 5.2 (offset 5) — inside A's fringe
5412 // after width normalization — so the blended kernel is
5413 // positive beyond A's bare w>0 extreme.
5414 let r = TabulatedResolution {
5415 ref_energies: Arc::new(vec![10.0, 1000.0]),
5416 kernels: Arc::new(vec![
5417 (vec![0.0, 5.0, 10.0, 20.0], vec![1.0, 0.1, 0.0, 0.0]),
5418 (vec![0.0, 1.0, 5.0, 6.0], vec![1.0, 0.8, 0.05, 0.0]),
5419 ]),
5420 flight_path_m: 25.0,
5421 };
5422 let e: f64 = 100.0; // strictly between the reference energies
5423 let got = r.kernel_support_ev(e);
5424
5425 // Non-circular cover: the actual blended kernel's w>0 extremes.
5426 let (offs, ws) = test_support::interpolated_kernel(&r, e);
5427 let dt_pos = offs
5428 .iter()
5429 .zip(&ws)
5430 .filter(|&(_, &w)| w > 0.0)
5431 .map(|(&o, _)| o)
5432 .fold(0.0f64, f64::max);
5433 let dt_neg = offs
5434 .iter()
5435 .zip(&ws)
5436 .filter(|&(_, &w)| w > 0.0)
5437 .map(|(&o, _)| -o)
5438 .fold(0.0f64, f64::max);
5439 let actual_excursion = exact_support(e, dt_pos, dt_neg, 25.0);
5440 assert!(
5441 got >= actual_excursion * (1.0 - 1e-12),
5442 "support must cover the actual blended kernel (incl. the \
5443 zero-weight fringe): got {got}, actual {actual_excursion}"
5444 );
5445
5446 // The fringe matters: the actual blend reaches beyond block
5447 // A's bare positive maximum (5 µs) scaled to the target width
5448 // — assert the blend truly is wider than a no-fringe reading
5449 // of block A would suggest, keeping this case load-bearing.
5450 let (_, s_lo) = trapezoidal_moments(&r.kernels[0].0, &r.kernels[0].1);
5451 let (_, s_hi) = trapezoidal_moments(&r.kernels[1].0, &r.kernels[1].1);
5452 let frac = (e.ln() - 10.0f64.ln()) / (1000.0f64.ln() - 10.0f64.ln());
5453 let s_t = s_lo * (s_hi / s_lo).powf(frac);
5454 let bare_positive_bound = 5.0 / s_lo * s_t;
5455 assert!(
5456 dt_pos > bare_positive_bound,
5457 "blend must extend into the zero-weight fringe: dt_pos {dt_pos}, \
5458 bare-positive bound {bare_positive_bound}"
5459 );
5460 }
5461
5462 /// The support must contain the footprint of the ACTUAL broadener:
5463 /// broadening a flat baseline with a single narrow dip must leave
5464 /// every target untouched whose window
5465 /// `[e − support(e), e + support(e)]` excludes the dip.
5466 /// Non-circular by construction — the oracle here is `broaden`
5467 /// itself, not the `exact_support` formula mirror.
5468 #[test]
5469 fn test_tabulated_kernel_support_contains_broadener_footprint() {
5470 // Asymmetric kernel with a dominant delayed (positive) tail.
5471 let r = TabulatedResolution {
5472 ref_energies: Arc::new(vec![100.0]),
5473 kernels: Arc::new(vec![(vec![-1.0, 0.0, 6.0], vec![0.2, 1.0, 0.7])]),
5474 flight_path_m: 25.0,
5475 };
5476 // Dense uniform grid; flat baseline with a single-point dip.
5477 let de = 0.05;
5478 let energies: Vec<f64> = (0..2001).map(|j| 50.0 + de * j as f64).collect();
5479 let f = 1000; // dip mid-grid, at ~100 eV
5480 let e_f = energies[f];
5481 let mut spectrum = vec![1.0; energies.len()];
5482 spectrum[f] = 0.0;
5483 let out = r.broaden(&energies, &spectrum).unwrap();
5484
5485 // Piecewise-linear reads touch spectrum[f] only for
5486 // e′ ∈ (energies[f−1], energies[f+1]); require one extra grid
5487 // step of margin so bracketing/interpolation edge effects
5488 // cannot straddle the window boundary.
5489 let margin = 2.0 * de;
5490 let (mut excluded_below, mut excluded_above) = (0usize, 0usize);
5491 let mut included_differs = false;
5492 let mut upside_differs = false;
5493 for (&e, &o) in energies.iter().zip(out.iter()) {
5494 let s = r.kernel_support_ev(e);
5495 if e + s + margin < e_f || e - s - margin > e_f {
5496 assert!(
5497 (o - 1.0).abs() < 1e-12,
5498 "target {e} eV (support {s}) must be untouched by a \
5499 dip at {e_f} eV outside its window; got {o}"
5500 );
5501 if e < e_f {
5502 excluded_below += 1;
5503 } else {
5504 excluded_above += 1;
5505 }
5506 } else if (o - 1.0).abs() > 1e-3 {
5507 included_differs = true;
5508 if e < e_f - margin {
5509 upside_differs = true;
5510 }
5511 }
5512 }
5513 // Non-vacuity: both exclusion regions were exercised, the dip
5514 // measurably alters at least one in-window target, and the
5515 // delayed tail reaches the dip from BELOW — the direction the
5516 // convolution gather loads.
5517 assert!(
5518 excluded_below > 0 && excluded_above > 0,
5519 "grid must exercise both exclusion regions \
5520 (below: {excluded_below}, above: {excluded_above})"
5521 );
5522 assert!(
5523 included_differs,
5524 "the dip must measurably alter at least one in-window target"
5525 );
5526 assert!(
5527 upside_differs,
5528 "the delayed tail must reach the dip from a target below it"
5529 );
5530 }
5531
5532 /// Same containment property, exercised through the
5533 /// BETWEEN-REFERENCES support arm: two width-scaled reference
5534 /// blocks bracket the grid, so every target energy uses the
5535 /// width-interpolated kernel and the closure-extent support bound.
5536 /// The oracle is `broaden` itself — fully independent of both the
5537 /// support formula and the interpolation implementation.
5538 #[test]
5539 fn test_tabulated_kernel_support_contains_broadener_footprint_between_refs() {
5540 let r = TabulatedResolution {
5541 ref_energies: Arc::new(vec![10.0, 1000.0]),
5542 kernels: Arc::new(vec![
5543 (vec![-2.0, 0.0, 12.0], vec![0.2, 1.0, 0.7]),
5544 (vec![-0.5, 0.0, 3.0], vec![0.2, 1.0, 0.7]),
5545 ]),
5546 flight_path_m: 25.0,
5547 };
5548 let de = 0.05;
5549 let energies: Vec<f64> = (0..2001).map(|j| 50.0 + de * j as f64).collect();
5550 let f = 1000; // dip mid-grid, at ~100 eV — between the refs
5551 let e_f = energies[f];
5552 let mut spectrum = vec![1.0; energies.len()];
5553 spectrum[f] = 0.0;
5554 let out = r.broaden(&energies, &spectrum).unwrap();
5555
5556 let margin = 2.0 * de;
5557 let (mut excluded, mut included_differs) = (0usize, false);
5558 for (&e, &o) in energies.iter().zip(out.iter()) {
5559 let s = r.kernel_support_ev(e);
5560 if e + s + margin < e_f || e - s - margin > e_f {
5561 assert!(
5562 (o - 1.0).abs() < 1e-12,
5563 "between-refs target {e} eV (support {s}) must be \
5564 untouched by a dip at {e_f} eV outside its window; got {o}"
5565 );
5566 excluded += 1;
5567 } else if (o - 1.0).abs() > 1e-3 {
5568 included_differs = true;
5569 }
5570 }
5571 assert!(
5572 excluded > 0,
5573 "grid must exercise the exclusion region between references"
5574 );
5575 assert!(
5576 included_differs,
5577 "the dip must measurably alter at least one in-window target"
5578 );
5579 }
5580
5581 #[test]
5582 fn piecewise_linear_bin_probabilities_pins_delta_and_partial_window() {
5583 let delta = |t: f64, edges: &[f64]| {
5584 piecewise_linear_bin_probabilities(&[t], &[3.5], edges)
5585 .expect("one-point kernel is a unit delta under normalization")
5586 };
5587 assert_eq!(delta(2.0, &[0.0, 1.0, 3.0, 5.0]), vec![0.0, 1.0, 0.0]);
5588 // The last bin is right-closed: a delta exactly on the final edge
5589 // belongs to it.
5590 assert_eq!(delta(5.0, &[0.0, 1.0, 3.0, 5.0]), vec![0.0, 0.0, 1.0]);
5591 // A delta outside every bin contributes nothing.
5592 assert_eq!(delta(9.0, &[0.0, 1.0, 3.0, 5.0]), vec![0.0, 0.0, 0.0]);
5593 let w3 = [0.0, 1.0, 0.0];
5594 assert!(
5595 piecewise_linear_bin_probabilities(
5596 &[0.0, 1.0, 1.0, 2.0],
5597 &[0.0, 1.0, 1.0, 0.0],
5598 &[0.5, 1.5]
5599 )
5600 .is_none()
5601 );
5602 for bad_times in [[0.0, f64::NAN, 2.0], [0.0, 1.0, f64::INFINITY]] {
5603 assert!(piecewise_linear_bin_probabilities(&bad_times, &w3, &[0.5, 1.5]).is_none());
5604 }
5605 for bad_edges in [[0.5, f64::NAN], [1.5, 0.5]] {
5606 assert!(
5607 piecewise_linear_bin_probabilities(&[0.0, 1.0, 2.0], &w3, &bad_edges).is_none()
5608 );
5609 }
5610
5611 // Support normalization: a triangle on [0, 2] integrates to one over
5612 // its full support, and a partial window reports the true fraction.
5613 let times = [0.0, 1.0, 2.0];
5614 let weights = [0.0, 4.0, 0.0]; // arbitrary scale — normalization removes it
5615 let full = piecewise_linear_bin_probabilities(×, &weights, &[0.0, 2.0])
5616 .expect("triangle integrates");
5617 assert!((full[0] - 1.0).abs() < 1e-15);
5618 let halves = piecewise_linear_bin_probabilities(×, &weights, &[0.0, 0.5, 1.0])
5619 .expect("triangle integrates");
5620 assert!((halves[0] - 0.125).abs() < 1e-15);
5621 assert!((halves[1] - 0.375).abs() < 1e-15);
5622 }
5623}
5624
5625#[cfg(test)]
5626mod width_convention_tests {
5627 use super::*;
5628
5629 /// Grid half-span, in units of the nominal width. Beyond 6 the remaining
5630 /// Gaussian mass is below 1e-16, so 12 is already generous.
5631 const SPAN_IN_WIDTHS: usize = 12;
5632
5633 /// Grid points per nominal width.
5634 ///
5635 /// Two errors scale as `(step/W)²` here: the trapezoidal second-moment
5636 /// quadrature, and the variance the one-bin-wide impulse carries in its own
5637 /// right (`step²/12`). At 50 samples per width both are ~1e-4 of the
5638 /// measured σ, two orders below the tolerances these tests assert, while
5639 /// the convolution cost scales as the square of the point count.
5640 const SAMPLES_PER_WIDTH: usize = 50;
5641
5642 /// The kernel's second moment, measured numerically rather than taken from
5643 /// the width parameter the kernel was built from.
5644 ///
5645 /// Broadens a unit impulse and reads the standard deviation back off the
5646 /// result. `nominal_width_ev` only sizes the grid — the measurement itself
5647 /// never uses it, so the result cannot agree with `gaussian_width` by
5648 /// construction. That independence is the point of the test.
5649 fn measured_sigma_ev(params: &ResolutionParams, center_ev: f64, nominal_width_ev: f64) -> f64 {
5650 let n: usize = 2 * SPAN_IN_WIDTHS * SAMPLES_PER_WIDTH + 1;
5651 let half_span_ev = SPAN_IN_WIDTHS as f64 * nominal_width_ev;
5652 let step = 2.0 * half_span_ev / (n - 1) as f64;
5653 let energies: Vec<f64> = (0..n)
5654 .map(|i| center_ev - half_span_ev + i as f64 * step)
5655 .collect();
5656 // A unit impulse at the centre bin: broadening it returns the kernel.
5657 let mut impulse = vec![0.0; n];
5658 impulse[(n - 1) / 2] = 1.0 / step;
5659
5660 let kernel = resolution_broaden(&energies, &impulse, params).expect("broadening runs");
5661
5662 let mass: f64 = kernel.iter().sum::<f64>() * step;
5663 assert!(
5664 (mass - 1.0).abs() < 1.0e-3,
5665 "kernel is not normalised on this span: mass {mass}"
5666 );
5667 let mean: f64 = kernel
5668 .iter()
5669 .zip(&energies)
5670 .map(|(k, e)| k * e * step)
5671 .sum::<f64>()
5672 / mass;
5673 let variance: f64 = kernel
5674 .iter()
5675 .zip(&energies)
5676 .map(|(k, e)| k * (e - mean) * (e - mean) * step)
5677 .sum::<f64>()
5678 / mass;
5679 variance.sqrt()
5680 }
5681
5682 /// `gaussian_width` returns W, and W/√2 is the standard deviation.
5683 ///
5684 /// This is the assertion that fixes the convention. If the width were a
5685 /// standard deviation instead, the measured σ would come back a factor √2
5686 /// larger than W/√2 and this fails by 41 %.
5687 #[test]
5688 fn gaussian_width_is_a_w_parameter_not_a_standard_deviation() {
5689 let center = 10.0;
5690 let params = ResolutionParams::new(25.0, 1.0, 0.0, 0.0).expect("valid params");
5691 let w = params.gaussian_width(center);
5692 assert!(w > 0.0, "test is vacuous without a width");
5693
5694 let measured = measured_sigma_ev(¶ms, center, w);
5695 let expected = w / SQRT_2;
5696 assert!(
5697 (measured - expected).abs() / expected < 2.0e-3,
5698 "measured σ {measured:.9} but W/√2 is {expected:.9} (W = {w:.9})"
5699 );
5700 // And it is NOT the standard deviation itself, by a clear margin.
5701 assert!(
5702 (measured - w).abs() / w > 0.25,
5703 "measured σ {measured:.9} is indistinguishable from W {w:.9}"
5704 );
5705 }
5706
5707 /// `fwhm()` is the full width at half maximum of the kernel it describes.
5708 #[test]
5709 fn fwhm_matches_the_kernel_measured_half_maximum() {
5710 let center = 10.0;
5711 let params = ResolutionParams::new(25.0, 1.0, 0.0, 0.0).expect("valid params");
5712 let w = params.gaussian_width(center);
5713 let measured_sigma = measured_sigma_ev(¶ms, center, w);
5714 // FWHM of a Gaussian in terms of its own standard deviation.
5715 let fwhm_from_measured = 2.0 * (2.0 * 2.0_f64.ln()).sqrt() * measured_sigma;
5716 let reported = params.fwhm(center);
5717 assert!(
5718 (reported - fwhm_from_measured).abs() / fwhm_from_measured < 2.0e-3,
5719 "fwhm() reports {reported:.9} but the kernel measures {fwhm_from_measured:.9}"
5720 );
5721 }
5722
5723 /// `FWHM_PER_W` is exactly what the previous open-coded expression produced.
5724 #[test]
5725 fn fwhm_per_w_constant_is_bit_exact() {
5726 assert_eq!(
5727 FWHM_PER_W.to_bits(),
5728 (2.0 * (2.0_f64.ln()).sqrt()).to_bits(),
5729 "the named constant changed the value it replaced"
5730 );
5731 }
5732
5733 /// `from_sigma` accepts a standard deviation and produces that σ.
5734 #[test]
5735 fn from_sigma_takes_a_standard_deviation() {
5736 let sigma_t_us = 1.0;
5737 let from_sigma = ResolutionParams::from_sigma(25.0, sigma_t_us, 0.0, 0.0).expect("valid");
5738 let direct = ResolutionParams::new(25.0, sigma_t_us, 0.0, 0.0).expect("valid");
5739
5740 // The converted parameters are √2 wider than the raw ones.
5741 assert!(
5742 (from_sigma.delta_t_us() - sigma_t_us * SQRT_2).abs() < 1.0e-15,
5743 "from_sigma did not apply W = σ·√2"
5744 );
5745
5746 // And the kernel it builds has the σ the caller asked for, in energy:
5747 // σ_E = 2·σ_t·E^{3/2}/(TOF_FACTOR·L), the ordinary propagation.
5748 let center = 10.0_f64;
5749 let expected_sigma_e =
5750 2.0 * sigma_t_us * center.powf(1.5) / (TOF_FACTOR * from_sigma.flight_path_m());
5751 let measured = measured_sigma_ev(&from_sigma, center, from_sigma.gaussian_width(center));
5752 assert!(
5753 (measured - expected_sigma_e).abs() / expected_sigma_e < 2.0e-3,
5754 "from_sigma kernel measures σ {measured:.9}, asked for {expected_sigma_e:.9}"
5755 );
5756 // The direct constructor, given the same number, is √2 narrower —
5757 // which is exactly the error the documentation used to invite.
5758 let measured_direct = measured_sigma_ev(&direct, center, direct.gaussian_width(center));
5759 assert!(
5760 (measured_direct * SQRT_2 - measured).abs() / measured < 5.0e-3,
5761 "the two constructors do not differ by √2"
5762 );
5763 }
5764
5765 /// `from_fwhm` accepts a full width at half maximum.
5766 #[test]
5767 fn from_fwhm_takes_a_full_width_at_half_maximum() {
5768 let fwhm_t_us = 1.0;
5769 let params = ResolutionParams::from_fwhm(25.0, fwhm_t_us, 0.0, 0.0).expect("valid");
5770 assert!(
5771 (params.delta_t_us() - fwhm_t_us / FWHM_PER_W).abs() < 1.0e-15,
5772 "from_fwhm did not apply W = FWHM/(2√ln2)"
5773 );
5774 }
5775
5776 /// `from_fwhm` is the conversion SAMMY's `Deltag` needs.
5777 ///
5778 /// `nereids_endf::sammy::sammy_to_nereids_resolution` divides Deltag by
5779 /// 2√(ln 2) to reach this module's convention; the two must agree, because
5780 /// the samtry baselines depend on that mapping being the right one.
5781 #[test]
5782 fn from_fwhm_agrees_with_the_sammy_deltag_conversion() {
5783 let delta_g = 0.022_f64; // tr007's BROADENING card value.
5784 let via_constructor = ResolutionParams::from_fwhm(25.0, delta_g, 0.0, 0.0).expect("valid");
5785 let sammy_mapping = delta_g / (2.0 * 2.0_f64.ln().sqrt());
5786 assert!(
5787 (via_constructor.delta_t_us() - sammy_mapping).abs() < 1.0e-15,
5788 "from_fwhm disagrees with the SAMMY Deltag conversion"
5789 );
5790 }
5791}
5792
5793#[cfg(test)]
5794mod flight_path_rebinding_tests {
5795 use super::*;
5796
5797 /// Rebinding the flight path scales the Gaussian energy width by 1/s.
5798 ///
5799 /// The oracle is the analytic law, not the code: a timing width Δt maps to
5800 /// an energy width `W_E = 2·Δt·E^{3/2}/(F·L)`, so the same Δt read against
5801 /// `L·s` gives `W_E/s`. A fit that frees `L_scale` builds its grid with
5802 /// `L·L_scale`; if the kernel keeps `L`, this is exactly the factor it is
5803 /// wrong by.
5804 #[test]
5805 fn rebinding_scales_the_gaussian_width_inversely() {
5806 let l_nom = 25.0;
5807 let delta_t = 1.0;
5808 let base = ResolutionParams::new(l_nom, delta_t, 0.0, 0.0).expect("valid");
5809 for s in [0.97_f64, 1.0, 1.03, 1.25] {
5810 let rebound = ResolutionFunction::Gaussian(base)
5811 .with_flight_path(l_nom * s)
5812 .expect("positive flight path");
5813 let ResolutionFunction::Gaussian(rebound) = rebound else {
5814 panic!("rebinding changed the family");
5815 };
5816 for e in [5.0_f64, 20.0, 100.0] {
5817 let analytic = 2.0 * delta_t * e.powf(1.5) / (TOF_FACTOR * l_nom * s);
5818 let got = rebound.gaussian_width(e);
5819 assert!(
5820 (got - analytic).abs() / analytic < 1.0e-14,
5821 "s={s}, E={e}: width {got:e} but the law gives {analytic:e}"
5822 );
5823 // And it is the base width divided by s, which is the
5824 // statement the fit depends on.
5825 let base_w = base.gaussian_width(e);
5826 assert!(
5827 (got - base_w / s).abs() / (base_w / s) < 1.0e-14,
5828 "s={s}, E={e}: rebound width is not base/s"
5829 );
5830 }
5831 }
5832 }
5833
5834 /// Rebinding does not touch the tabulated kernel itself.
5835 ///
5836 /// The stored offsets are emission times. The flight path belongs to the
5837 /// map they are applied through, so a rebind must leave every offset and
5838 /// weight byte-identical — if it resynthesized or rescaled them it would
5839 /// be changing the moderator, not the geometry.
5840 #[test]
5841 fn rebinding_leaves_the_tabulated_kernel_byte_identical() {
5842 let offsets = vec![-2.0, -1.0, 0.0, 1.0, 3.0];
5843 let weights = vec![0.1, 0.6, 1.0, 0.5, 0.05];
5844 let base = TabulatedResolution::from_kernels(
5845 vec![5.0, 50.0],
5846 vec![
5847 (offsets.clone(), weights.clone()),
5848 (offsets.clone(), weights.clone()),
5849 ],
5850 25.0,
5851 )
5852 .expect("valid kernel table");
5853
5854 let rebound = base.with_flight_path(25.0 * 1.03).expect("positive");
5855 assert_eq!(rebound.flight_path_m(), 25.0 * 1.03);
5856 assert_eq!(base.ref_energies, rebound.ref_energies);
5857 for (i, ((b_off, b_w), (r_off, r_w))) in
5858 base.kernels.iter().zip(rebound.kernels.iter()).enumerate()
5859 {
5860 assert_eq!(
5861 b_off.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
5862 r_off.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
5863 "rebinding moved the emission-time offsets of block {i}"
5864 );
5865 assert_eq!(
5866 b_w.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
5867 r_w.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
5868 "rebinding changed the weights of block {i}"
5869 );
5870 }
5871 }
5872
5873 /// A non-positive flight path is refused rather than silently accepted.
5874 #[test]
5875 fn rebinding_refuses_a_non_physical_flight_path() {
5876 let params = ResolutionParams::new(25.0, 1.0, 0.0, 0.0).expect("valid");
5877 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
5878 assert!(
5879 ResolutionFunction::Gaussian(params)
5880 .with_flight_path(bad)
5881 .is_err(),
5882 "flight path {bad} was accepted"
5883 );
5884 }
5885 }
5886}