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