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