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 3.2 (Resolution Broadening), Eq. IV B 3.8
16//!
17//! ## Physics
18//!
19//! For a time-of-flight instrument, the energy resolution is:
20//!
21//!   (ΔE/E)² = (2·Δt/t)² + (2·ΔL/L)²
22//!
23//! where t = L/v is the neutron time-of-flight, Δt is the total timing
24//! uncertainty, and ΔL is the flight path uncertainty. Since t ∝ 1/√E,
25//! the timing contribution gives ΔE ∝ E^(3/2) while the path contribution
26//! gives ΔE ∝ E.
27//!
28//! The broadened cross-section is:
29//!
30//!   σ_res(E) = ∫ R(E, E') · σ(E') dE'
31//!
32//! When Deltae = 0, R is a pure Gaussian (Iesopr=1):
33//!   R(E, E') = exp(-(E-E')²/Wg²) / (Wg·√π)
34//!
35//! When Deltae > 0, R is the convolution of a Gaussian with an exponential
36//! tail (Iesopr=3):
37//!   R(E, E') ∝ exp(2·C·A + C²) · erfc(C + A)
38//!
39//! where C = Wg/(2·We), A = (E - E')/Wg, Wg = Gaussian width, We = exponential
40//! width. This is the analytical result for convolving exp(-x²/Wg²) with
41//! exp(-x/We)·H(x).
42
43use nereids_core::constants::{DIVISION_FLOOR, NEAR_ZERO_FLOOR};
44use std::fmt;
45use std::sync::Arc;
46
47/// TOF conversion factor: t[μs] = TOF_FACTOR × L[m] / √(E[eV]).
48///
49/// Derived from t = L / √(2E/m_n), converting to microseconds:
50///   TOF_FACTOR = 1e6 / √(2 × EV_TO_JOULES / NEUTRON_MASS_KG)
51///
52/// Uses CODATA 2018 values (both exact in the 2019 SI).
53const TOF_FACTOR: f64 = 72.298_254_398_292_8;
54
55/// Errors from resolution broadening operations.
56#[derive(Debug, PartialEq)]
57pub enum ResolutionError {
58    /// The energy grid is not sorted in ascending order.
59    UnsortedEnergies,
60    /// The energy grid and data arrays have mismatched lengths.
61    LengthMismatch { energies: usize, data: usize },
62}
63
64impl fmt::Display for ResolutionError {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        match self {
67            Self::UnsortedEnergies => write!(
68                f,
69                "energy grid must be sorted in non-descending order for binary search"
70            ),
71            Self::LengthMismatch { energies, data } => write!(
72                f,
73                "energy grid length ({}) must match data length ({})",
74                energies, data
75            ),
76        }
77    }
78}
79
80impl std::error::Error for ResolutionError {}
81
82/// Errors from `ResolutionParams` construction.
83#[derive(Debug, PartialEq)]
84pub enum ResolutionParamsError {
85    /// Flight path must be positive and finite.
86    InvalidFlightPath(f64),
87    /// Timing uncertainty must be non-negative and finite.
88    InvalidDeltaT(f64),
89    /// Path length uncertainty must be non-negative and finite.
90    InvalidDeltaL(f64),
91    /// Exponential tail parameter must be non-negative and finite.
92    InvalidDeltaE(f64),
93}
94
95impl fmt::Display for ResolutionParamsError {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        match self {
98            Self::InvalidFlightPath(v) => {
99                write!(f, "flight_path_m must be positive and finite, got {v}")
100            }
101            Self::InvalidDeltaT(v) => {
102                write!(f, "delta_t_us must be non-negative and finite, got {v}")
103            }
104            Self::InvalidDeltaL(v) => {
105                write!(f, "delta_l_m must be non-negative and finite, got {v}")
106            }
107            Self::InvalidDeltaE(v) => {
108                write!(f, "delta_e_us must be non-negative and finite, got {v}")
109            }
110        }
111    }
112}
113
114impl std::error::Error for ResolutionParamsError {}
115
116/// Resolution function parameters for time-of-flight instruments.
117#[derive(Debug, Clone, Copy)]
118pub struct ResolutionParams {
119    /// Flight path length in meters (source to detector).
120    flight_path_m: f64,
121    /// Total timing uncertainty (1σ Gaussian) in microseconds.
122    /// Combines moderator pulse width, detector timing, and electronics.
123    delta_t_us: f64,
124    /// Flight path uncertainty (1σ Gaussian) in meters.
125    delta_l_m: f64,
126    /// Exponential tail parameter (SAMMY Deltae, raw SAMMY units).
127    ///
128    /// When zero, pure Gaussian broadening is used (SAMMY Iesopr=1).
129    /// When positive, the kernel is the convolution of a Gaussian with an
130    /// exponential tail (SAMMY Iesopr=3).
131    ///
132    /// SAMMY Ref: `RslResolutionFunction_M.f90` getCo2, `rsl/mrsl4.f90` Wdsint.
133    delta_e_us: f64,
134}
135
136impl ResolutionParams {
137    /// Create validated resolution parameters.
138    ///
139    /// # Arguments
140    /// * `flight_path_m` — Flight path length in meters (must be > 0).
141    /// * `delta_t_us` — Timing uncertainty in microseconds (must be >= 0).
142    /// * `delta_l_m` — Flight path uncertainty in meters (must be >= 0).
143    /// * `delta_e_us` — Exponential tail parameter in SAMMY Deltae units
144    ///   (must be >= 0). When 0, pure Gaussian broadening is used.
145    ///
146    /// # Errors
147    /// Returns `ResolutionParamsError::InvalidFlightPath` if `flight_path_m <= 0.0`
148    /// or is not finite.
149    /// Returns `ResolutionParamsError::InvalidDeltaT` if `delta_t_us < 0.0` or is
150    /// not finite.
151    /// Returns `ResolutionParamsError::InvalidDeltaL` if `delta_l_m < 0.0` or is
152    /// not finite.
153    /// Returns `ResolutionParamsError::InvalidDeltaE` if `delta_e_us < 0.0` or is
154    /// not finite.
155    pub fn new(
156        flight_path_m: f64,
157        delta_t_us: f64,
158        delta_l_m: f64,
159        delta_e_us: f64,
160    ) -> Result<Self, ResolutionParamsError> {
161        if !flight_path_m.is_finite() || flight_path_m <= 0.0 {
162            return Err(ResolutionParamsError::InvalidFlightPath(flight_path_m));
163        }
164        if !delta_t_us.is_finite() || delta_t_us < 0.0 {
165            return Err(ResolutionParamsError::InvalidDeltaT(delta_t_us));
166        }
167        if !delta_l_m.is_finite() || delta_l_m < 0.0 {
168            return Err(ResolutionParamsError::InvalidDeltaL(delta_l_m));
169        }
170        if !delta_e_us.is_finite() || delta_e_us < 0.0 {
171            return Err(ResolutionParamsError::InvalidDeltaE(delta_e_us));
172        }
173        Ok(Self {
174            flight_path_m,
175            delta_t_us,
176            delta_l_m,
177            delta_e_us,
178        })
179    }
180
181    /// Returns the flight path length in meters.
182    #[must_use]
183    pub fn flight_path_m(&self) -> f64 {
184        self.flight_path_m
185    }
186
187    /// Total timing uncertainty (1σ Gaussian) in microseconds.
188    ///
189    /// The factor of 2 in [`gaussian_width()`](Self::gaussian_width) comes from
190    /// the energy-TOF derivative dE/E = 2·dt/t, not from a σ-to-FWHM conversion.
191    #[must_use]
192    pub fn delta_t_us(&self) -> f64 {
193        self.delta_t_us
194    }
195
196    /// Returns the flight path uncertainty (1σ Gaussian) in meters.
197    #[must_use]
198    pub fn delta_l_m(&self) -> f64 {
199        self.delta_l_m
200    }
201
202    /// Returns the exponential tail parameter (SAMMY Deltae units).
203    #[must_use]
204    pub fn delta_e_us(&self) -> f64 {
205        self.delta_e_us
206    }
207
208    /// Whether the exponential tail is active (Deltae > 0, SAMMY Iesopr=3).
209    #[must_use]
210    pub fn has_exponential_tail(&self) -> bool {
211        self.delta_e_us > NEAR_ZERO_FLOOR
212    }
213
214    /// Exponential tail width Widexp(E) in eV.
215    ///
216    /// SAMMY Ref: `rsl/mrsl4.f90` Wdsint lines 55-56 (Kedxfw=false path):
217    ///   `Widexp = E * Co2 * sqrt(E)` where `Co2 = 2·Deltae / (Sm2·Dist)`.
218    ///
219    /// Combined: `Widexp = 2·Deltae·E^(3/2) / (TOF_FACTOR·L)`.
220    #[must_use]
221    pub fn exp_width(&self, energy_ev: f64) -> f64 {
222        if energy_ev <= 0.0 || self.delta_e_us <= 0.0 {
223            return 0.0;
224        }
225        2.0 * self.delta_e_us * energy_ev.powf(1.5) / (TOF_FACTOR * self.flight_path_m)
226    }
227
228    /// Gaussian resolution width σ_E(E) in eV.
229    ///
230    /// Combines timing and flight-path contributions in quadrature:
231    ///   σ_E² = (2·Δt/t × E)² + (2·ΔL/L × E)²
232    ///
233    /// where t = TOF_FACTOR × L / √E is the time-of-flight in μs.
234    #[must_use]
235    pub fn gaussian_width(&self, energy_ev: f64) -> f64 {
236        if energy_ev <= 0.0 || self.flight_path_m <= 0.0 {
237            return 0.0;
238        }
239
240        // Timing contribution: σ_t = 2 × Δt × E^(3/2) / (TOF_FACTOR × L)
241        let timing =
242            2.0 * self.delta_t_us * energy_ev.powf(1.5) / (TOF_FACTOR * self.flight_path_m);
243
244        // Path length contribution: σ_L = 2 × ΔL × E / L
245        let path = 2.0 * self.delta_l_m * energy_ev / self.flight_path_m;
246
247        (timing * timing + path * path).sqrt()
248    }
249
250    /// FWHM of the resolution function at energy E, in eV.
251    #[must_use]
252    pub fn fwhm(&self, energy_ev: f64) -> f64 {
253        2.0 * (2.0_f64.ln()).sqrt() * self.gaussian_width(energy_ev)
254    }
255}
256
257/// Apply Gaussian resolution broadening to cross-section data.
258///
259/// Convolves the input cross-sections with a Gaussian kernel whose width
260/// varies with energy according to the instrument resolution function.
261///
262/// # Arguments
263/// * `energies` — Energy grid in eV (must be sorted ascending).
264/// * `cross_sections` — Cross-sections in barns at each energy point.
265/// * `params` — Resolution function parameters.
266///
267/// # Returns
268/// Resolution-broadened cross-sections on the same energy grid.
269///
270/// # Errors
271/// Returns [`ResolutionError::LengthMismatch`] if the arrays differ in length,
272/// or [`ResolutionError::UnsortedEnergies`] if the energy grid is not sorted
273/// in non-descending order.
274pub fn resolution_broaden(
275    energies: &[f64],
276    cross_sections: &[f64],
277    params: &ResolutionParams,
278) -> Result<Vec<f64>, ResolutionError> {
279    validate_inputs(energies, cross_sections)?;
280    Ok(resolution_broaden_presorted(
281        energies,
282        cross_sections,
283        params,
284    ))
285}
286
287/// Check that the energy grid is sorted and that its length matches the data.
288fn validate_inputs(energies: &[f64], data: &[f64]) -> Result<(), ResolutionError> {
289    if energies.len() != data.len() {
290        return Err(ResolutionError::LengthMismatch {
291            energies: energies.len(),
292            data: data.len(),
293        });
294    }
295    if !energies.windows(2).all(|w| w[0] <= w[1]) {
296        return Err(ResolutionError::UnsortedEnergies);
297    }
298    Ok(())
299}
300
301// ─── Xcoef quadrature weights ──────────────────────────────────────────────────
302
303/// Compute SAMMY's 4-point quadrature weights for a non-uniform energy grid.
304///
305/// Replaces the simple trapezoidal rule `de = (E[j+1] - E[j-1]) / 2` with
306/// SAMMY's higher-order scheme from Eq. IV B 3.8 (page 80 of SAMMY manual R3).
307///
308/// SAMMY Ref: `convolution/DopplerAndResolutionBroadener.cpp`, `setXcoefWeights()`.
309///
310/// The weights include a correction term x2(k) that accounts for non-uniform
311/// grid spacing, providing 4th-order accuracy on smooth grids.
312///
313/// Note: the returned weights are 12x the quantity in Eq. IV B 3.8. This
314/// constant factor cancels during normalization (sum/norm), so the broadened
315/// result is independent of the scaling.
316fn compute_xcoef_weights(energies: &[f64]) -> Vec<f64> {
317    let n = energies.len();
318    if n == 0 {
319        return vec![];
320    }
321    if n == 1 {
322        return vec![1.0];
323    }
324
325    // SAMMY's 4-point quadrature weights (Eq. IV B 3.8, SAMMY Manual R3 p80).
326    //
327    // Uses a sliding window of 5 consecutive energies E[0..4] to compute
328    // coefficients A[0..5] at each grid point k:
329    //
330    //   A[0] = v1  (k >= 2)
331    //   A[1] = 5·v2  (k >= 1)
332    //   A[2] = 5·v3  (k < n-1)
333    //   A[3] = v4  (k < n-2)
334    //   A[4] = (v3² - v1²)/v2   curvature correction  (k >= 2)
335    //   A[5] = -(v4² - v2²)/v3  curvature correction  (k >= 1)
336    //
337    // where v1..v4 are consecutive grid spacings around point k.
338    //
339    // The result is 12× Eq. IV B 3.8; this constant factor cancels during
340    // normalization (sum/norm) in the broadening loop.
341    //
342    // SAMMY Ref: `convolution/DopplerAndResolutionBroadener.cpp` lines 365-457
343    let mut weights = vec![0.0f64; n];
344
345    // Sliding window: e[j] holds energies relative to current k.
346    // At loop start for k: e[0]=E[k-2], e[1]=E[k-1], e[2]=E[k],
347    //                       e[3]=E[k+1], e[4]=E[k+2]
348    // Out-of-bounds positions are 0.0 (matching SAMMY's convention).
349    let mut e = [0.0f64; 5];
350    e[3] = energies[0];
351    if n > 1 {
352        e[4] = energies[1];
353    }
354
355    for k in 0..n {
356        // Shift window left.
357        e[0] = e[1];
358        e[1] = e[2];
359        e[2] = e[3];
360        e[3] = e[4];
361        e[4] = if k + 2 < n { energies[k + 2] } else { 0.0 };
362
363        let v1 = e[1] - e[0];
364        let v2 = e[2] - e[1];
365        let v3 = e[3] - e[2];
366        let v4 = e[4] - e[3];
367
368        let mut a = [0.0f64; 6];
369
370        if k >= 2 {
371            a[0] = v1;
372            // Curvature correction: x2(k-2) = (v3² - v1²) / v2
373            if v2.abs() > NEAR_ZERO_FLOOR {
374                a[4] = (v3 * v3 - v1 * v1) / v2;
375            }
376        }
377        if k >= 1 {
378            a[1] = 5.0 * v2;
379            // Curvature correction: -x2(k-1) = -(v4² - v2²) / v3
380            if v3.abs() > NEAR_ZERO_FLOOR {
381                a[5] = -(v4 * v4 - v2 * v2) / v3;
382            }
383        }
384        if k != n - 1 {
385            a[2] = 5.0 * v3;
386        }
387        if k < n.saturating_sub(2) {
388            a[3] = v4;
389        }
390
391        // Boundary overrides (SAMMY source lines 446-450).
392        if k == n.saturating_sub(2) {
393            a[5] = 0.0;
394        }
395        if k == n - 1 {
396            a[4] = 0.0;
397            a[5] = 0.0;
398        }
399
400        weights[k] = a.iter().sum::<f64>();
401    }
402
403    weights
404}
405
406/// Compute erfc(x) using the existing `exerfc` function.
407///
408/// erfc(x) = exp(-x²) · exerfc(x) / √π
409///
410/// For x < 0: erfc(-|x|) = 2 - erfc(|x|)
411fn erfc_from_exerfc(x: f64) -> f64 {
412    const SQRT_PI: f64 = 1.772_453_850_905_516;
413    if x >= 0.0 {
414        (-x * x).exp() * exerfc(x) / SQRT_PI
415    } else {
416        let xp = -x;
417        2.0 - (-xp * xp).exp() * exerfc(xp) / SQRT_PI
418    }
419}
420
421// ─── Scaled complementary error function ───────────────────────────────────────
422
423/// Compute exp(x²)·erfc(x)·√π, numerically stable for all x.
424///
425/// SAMMY Ref: `fnc/exerfc.f90`.
426///
427/// Uses rational approximation for |x| < 5.01 and asymptotic expansion
428/// (Abramowitz & Stegun 7.1.23) for |x| >= 5.01.
429pub(crate) fn exerfc(x: f64) -> f64 {
430    const SQRT_PI: f64 = 1.772_453_850_905_516;
431    const TWO_SQRT_PI: f64 = 3.544_907_701_811_032;
432    const XMAX: f64 = 5.01;
433    // Rational approximation coefficients (from SAMMY's exerfc.f90)
434    const A1: f64 = 8.584_076_57e-1;
435    const A2: f64 = 3.078_181_93e-1;
436    const A3: f64 = 6.383_238_91e-2;
437    const A4: f64 = 1.824_050_75e-4;
438    const A5: f64 = 6.509_742_65e-1;
439    const A6: f64 = 2.294_848_19e-1;
440    const A7: f64 = 3.403_018_23e-2;
441
442    if x < 0.0 {
443        let xp = -x;
444        if xp > XMAX {
445            TWO_SQRT_PI - asympt(xp)
446        } else {
447            let a =
448                (A1 + xp * (A2 + xp * (A3 - xp * A4))) / (1.0 + xp * (A5 + xp * (A6 + xp * A7)));
449            let b = SQRT_PI + xp * (2.0 - a);
450            let a_rat = b / (xp * b + 1.0);
451            TWO_SQRT_PI * (x * x).exp() - a_rat
452        }
453    } else if x > XMAX {
454        asympt(x)
455    } else if x > 0.0 {
456        let a = (A1 + x * (A2 + x * (A3 - x * A4))) / (1.0 + x * (A5 + x * (A6 + x * A7)));
457        let b = SQRT_PI + x * (2.0 - a);
458        b / (x * b + 1.0)
459    } else {
460        SQRT_PI
461    }
462}
463
464/// Asymptotic expansion of exp(x²)·erfc(x)·√π for large positive x.
465///
466/// SAMMY Ref: `fnc/exerfc.f90`, Asympt function.
467/// Uses Abramowitz & Stegun 7.1.23.
468fn asympt(x: f64) -> f64 {
469    if x == 0.0 {
470        return 0.0;
471    }
472    let e = 1.0 / x;
473    if e == 0.0 {
474        return 0.0;
475    }
476    let b = 1.0 / (x * x);
477    let mut a = 1.0;
478    let mut c = b * 0.5;
479    for n in 1..=40 {
480        a -= c;
481        c *= -(n as f64 + 0.5) * b;
482        if (a - c) == a || (c / a).abs() < 1e-8 {
483            break;
484        }
485    }
486    a * e
487}
488
489/// Compute the Gaussian+exponential combined kernel weight Z(A, B).
490///
491/// Returns √π · exp(-A² + B²) · erfc(B), computed via exerfc for stability.
492///
493/// SAMMY Ref: `rsl/mrsl1.f90` lines 467-484 (Resbrd, Iesopr=3 path).
494///
495/// When B >= 0: `Z = exp(-A²) · Exerfc(B)`
496/// When B < 0:  `Z = Xxerfc(B, A)` which is the same mathematical function
497///   computed with different numerical strategy for stability.
498fn gauss_exp_kernel(a: f64, b: f64) -> f64 {
499    if b >= 0.0 {
500        let exp_neg_a2 = (-a * a).exp();
501        if exp_neg_a2 == 0.0 {
502            return 0.0;
503        }
504        exp_neg_a2 * exerfc(b)
505    } else {
506        // Xxerfc(B, A): compute exp(-A² + B²) · erfc(-B) · √π
507        // Using the same rational approximation as exerfc but for negative B.
508        //
509        // SAMMY Ref: `fnc/xxerfc.f90`.
510        xxerfc(b, a)
511    }
512}
513
514/// Compute exp(-xxx² + xx²) · erfc(-xx) · √π for xx assumed negative (B < 0).
515///
516/// SAMMY Ref: `fnc/xxerfc.f90`. Note: SAMMY says "Xx is assumed positive"
517/// but the caller passes B < 0 as Xx. The code handles this by immediately
518/// computing X = -Xx (which is positive).
519///
520/// When x = -xx exceeds XMAX, the rational approximation loses accuracy.
521/// We switch to `exp(-xxx²) · asympt(x)`, mirroring exerfc's large-argument
522/// path.
523fn xxerfc(xx: f64, xxx: f64) -> f64 {
524    const SQRT_PI: f64 = 1.772_453_850_905_516;
525    const XMAX: f64 = 5.01;
526    const A1: f64 = 8.584_076_57e-1;
527    const A2: f64 = 3.078_181_93e-1;
528    const A3: f64 = 6.383_238_91e-2;
529    const A4: f64 = 1.824_050_75e-4;
530    const A5: f64 = 6.509_742_65e-1;
531    const A6: f64 = 2.294_848_19e-1;
532    const A7: f64 = 3.403_018_23e-2;
533
534    let x = -xx; // x is positive (xx is B < 0)
535
536    // For large x, the rational approximation loses accuracy.
537    // exp(-xxx² + x²)·erfc(x)·√π = exp(-xxx²)·[exp(x²)·erfc(x)·√π]
538    //                              = exp(-xxx²)·asympt(x)
539    if x > XMAX {
540        return (-xxx * xxx).exp() * asympt(x);
541    }
542
543    let a_rat = (A1 + x * (A2 + x * (A3 - x * A4))) / (1.0 + x * (A5 + x * (A6 + x * A7)));
544    let b_int = SQRT_PI + x * (2.0 - a_rat);
545    let a_final = b_int / (x * b_int + 1.0);
546    // exp(-xxx² + x²) = exp(-A² + B²) since x = -B, xx = B
547    let exp_term = (-xxx * xxx + x * x).exp();
548    SQRT_PI * 2.0 * exp_term - a_final * (-xxx * xxx).exp()
549}
550
551/// Compute the energy shift for the Gaussian+exponential kernel peak.
552///
553/// Finds the peak of the combined kernel relative to E=0 via Newton-Raphson
554/// iteration. This centers the convolution window on the kernel maximum.
555///
556/// SAMMY Ref: `rsl/mrsl5.f90`, Shftge function.
557///
558/// # Arguments
559/// * `c` — Mixing parameter: Widgau / (2·Widexp)
560/// * `widgau` — Gaussian resolution width (eV)
561///
562/// # Returns
563/// The energy shift Est (eV) to apply to the measurement energy.
564fn shftge(c: f64, widgau: f64) -> f64 {
565    const ONE_OVER_SQRT_PI: f64 = 0.564_189_583_547_756_3;
566    const SMALL: f64 = 0.01;
567
568    let ax = c;
569    let bx = widgau;
570
571    // Initial guess
572    let mut x0 = if ax > ONE_OVER_SQRT_PI { ax } else { 0.0 };
573
574    let f0_initial = ax * exerfc(x0) - 1.0;
575    let mut f0 = f0_initial;
576    let fff = f0;
577
578    for _iter in 0..100 {
579        let f = ax * exerfc(x0) - 1.0;
580        let xma = x0 - ax;
581        let q = 1.0 - 2.0 * x0 * xma;
582        let delx = if q.abs() < NEAR_ZERO_FLOOR {
583            // q ≈ 0: division would overflow; accept current estimate.
584            break;
585        } else if xma * xma - q * f > 0.0 {
586            let disc = (xma * xma - q * f).sqrt();
587            if xma > 0.0 {
588                (-xma + disc) / q
589            } else {
590                (-xma - disc) / q
591            }
592        } else {
593            if xma.abs() < NEAR_ZERO_FLOOR {
594                break;
595            }
596            -f * 0.5 / xma
597        };
598        let x1 = x0 + delx;
599        let shftg = (ax - x1) * bx;
600        if (x1 - x0).abs() / x1.abs().max(1.0) < SMALL
601            && fff.abs() > NEAR_ZERO_FLOOR
602            && (f - f0).abs() / fff.abs() < SMALL
603        {
604            return shftg;
605        }
606        f0 = f;
607        x0 = x1;
608    }
609
610    (ax - x0) * bx
611}
612
613/// Threshold for the ratio C = W_g / (2·W_e) above which the exponential
614/// tail is negligible and the pure Gaussian PW-linear path is used instead.
615///
616/// At C = 2.5, erfc(2.5) ≈ 0.0005, so the exp tail contributes <0.05% of the
617/// kernel integral.  Using the pure Gaussian path at this threshold introduces
618/// negligible systematic error while enabling the more accurate PW-linear
619/// integration and adaptive intermediate point insertion.
620const EXP_TAIL_NEGLIGIBLE_C: f64 = 2.5;
621
622/// Resolution broadening assuming the energy grid is already validated
623/// (sorted ascending, same length as cross_sections).
624///
625/// For each broadening energy, selects the optimal integration method:
626/// - **PW-linear Gaussian** (exact, second-order): when `delta_e == 0` or
627///   the ratio C = W_g/(2·W_e) > [`EXP_TAIL_NEGLIGIBLE_C`] (exp tail negligible).
628/// - **Combined Gaussian+exp kernel** with SAMMY Xcoef quadrature: when the
629///   exponential tail is significant (C ≤ threshold).
630///
631/// SAMMY Ref: `rsl/mrsl1.f90` Resbrd, `convolution/DopplerAndResolutionBroadener.cpp`
632pub(crate) fn resolution_broaden_presorted(
633    energies: &[f64],
634    cross_sections: &[f64],
635    params: &ResolutionParams,
636) -> Vec<f64> {
637    let n = energies.len();
638    if n == 0 {
639        return vec![];
640    }
641
642    // Precompute Xcoef weights (used only by the combined kernel path).
643    // Even if some energies take the PW-linear path, we compute weights for
644    // the full grid — cheaper than branching per-energy.
645    let xcoef = if params.has_exponential_tail() {
646        compute_xcoef_weights(energies)
647    } else {
648        vec![]
649    };
650    let n_sigma = 5.0; // Integrate out to 5σ for Gaussian
651    let mut broadened = vec![0.0f64; n];
652
653    for i in 0..n {
654        let e = energies[i];
655        let widgau = params.gaussian_width(e);
656
657        if widgau < NEAR_ZERO_FLOOR {
658            broadened[i] = cross_sections[i];
659            continue;
660        }
661
662        // Per-energy decision: use combined kernel only when the exp tail
663        // is significant at THIS energy.
664        let widexp = params.exp_width(e);
665        let use_combined =
666            widexp > NEAR_ZERO_FLOOR && widgau / (2.0 * widexp) <= EXP_TAIL_NEGLIGIBLE_C;
667
668        // Compute integration limits.
669        let (e_low, e_high) = if use_combined {
670            // SAMMY Ref: mrsl4.f90 lines 57-65
671            let wlow = n_sigma * widgau;
672            let rwid = widgau / widexp;
673            let wup = if rwid <= 1.0 {
674                6.25 * widexp
675            } else if rwid <= 2.0 {
676                n_sigma * (3.0 - rwid) * widgau
677            } else {
678                n_sigma * widgau
679            };
680            (e - wlow, e + wup)
681        } else {
682            (e - n_sigma * widgau, e + n_sigma * widgau)
683        };
684
685        let j_lo = energies.partition_point(|&ej| ej < e_low);
686        let j_hi = energies.partition_point(|&ej| ej <= e_high);
687
688        if j_hi.saturating_sub(j_lo) <= 1 {
689            broadened[i] = cross_sections[i];
690            continue;
691        }
692
693        let mut sum = 0.0;
694        let mut norm = 0.0;
695
696        if use_combined {
697            // Combined Gaussian + exponential kernel (SAMMY Iesopr=3)
698            // with 4-point Xcoef quadrature weights.
699            // SAMMY Ref: mrsl1.f90 lines 455-484
700            let c = widgau * 0.5 / widexp;
701            let est = shftge(c, widgau);
702            let y = c * widgau + e - est;
703
704            for j in j_lo..j_hi {
705                let ee = energies[j];
706                let a = (e - est - ee) / widgau;
707                let b = (y - ee) / widgau;
708                let z = gauss_exp_kernel(a, b);
709                let wt = xcoef[j] * z;
710                sum += wt * cross_sections[j];
711                norm += wt;
712            }
713        } else {
714            // Pure Gaussian kernel with piecewise-linear exact integration.
715            //
716            // For each interval [E_j, E_{j+1}], integrate G(E_i - E') × σ_linear(E')
717            // exactly, where G(x) = exp(-x²/W²) / (W√π).
718            //
719            // Substituting u = (E' - E_i)/W, dE' = W du:
720            //   ∫ G × [σ_j + slope×(E'-E_j)] dE'
721            //   = (1/√π) ∫ exp(-u²) [σ_j + slope×W×(u - a_j)] du
722            //
723            // With I₀ = erf(a_{j+1}) - erf(a_j) and
724            //      I₁ = (exp(-a_j²) - exp(-a_{j+1}²)) / 2:
725            //
726            // The normalization integral is I₀/2, so after sum/norm (2 cancels):
727            //   sum += σ_j × I₀ + slope × W × (2/√π × I₁ - a_j × I₀)
728            //   norm += I₀
729            //
730            // The factor 2/√π on I₁ comes from the u·exp(-u²) integral
731            // needing to match the normalization convention erf(x) = 2/√π ∫ exp(-t²) dt.
732            const TWO_OVER_SQRT_PI: f64 = std::f64::consts::FRAC_2_SQRT_PI;
733            let inv_w = 1.0 / widgau;
734            for j in j_lo..j_hi.saturating_sub(1) {
735                let e_j = energies[j];
736                let e_j1 = energies[j + 1];
737                let h = e_j1 - e_j;
738                if h < NEAR_ZERO_FLOOR {
739                    continue;
740                }
741
742                let a_j = (e_j - e) * inv_w;
743                let a_j1 = (e_j1 - e) * inv_w;
744
745                // I₀ = erf(a_{j+1}) - erf(a_j) = erfc(a_j) - erfc(a_{j+1})
746                let erfc_aj = erfc_from_exerfc(a_j);
747                let erfc_aj1 = erfc_from_exerfc(a_j1);
748                let i0 = erfc_aj - erfc_aj1;
749
750                if i0 < NEAR_ZERO_FLOOR {
751                    continue;
752                }
753
754                // I₁ = (exp(-a_j²) - exp(-a_{j+1}²)) / 2
755                let i1 = ((-a_j * a_j).exp() - (-a_j1 * a_j1).exp()) * 0.5;
756
757                let slope = (cross_sections[j + 1] - cross_sections[j]) / h;
758
759                // σ_j × I₀ + slope × W × (2/√π × I₁ - a_j × I₀)
760                sum += cross_sections[j] * i0 + slope * widgau * (TWO_OVER_SQRT_PI * i1 - a_j * i0);
761                norm += i0;
762            }
763        }
764
765        if norm > DIVISION_FLOOR {
766            broadened[i] = sum / norm;
767        } else {
768            broadened[i] = cross_sections[i];
769        }
770    }
771
772    broadened
773}
774
775/// Apply resolution broadening to transmission data.
776///
777/// This is the same Gaussian convolution but applied to transmission
778/// spectra rather than cross-sections. The distinction matters because
779/// resolution broadening of transmission is physically different from
780/// broadening cross-sections (Beer-Lambert law is nonlinear).
781///
782/// # Arguments
783/// * `energies` — Energy grid in eV (sorted ascending).
784/// * `transmission` — Transmission values (0 to 1) at each energy point.
785/// * `params` — Resolution function parameters.
786///
787/// # Returns
788/// Resolution-broadened transmission on the same energy grid.
789///
790/// # Errors
791/// Returns [`ResolutionError`] if the energy grid is unsorted or array
792/// lengths do not match.
793pub fn resolution_broaden_transmission(
794    energies: &[f64],
795    transmission: &[f64],
796    params: &ResolutionParams,
797) -> Result<Vec<f64>, ResolutionError> {
798    // The convolution kernel is the same; only the interpretation differs.
799    // Validation is handled by resolution_broaden.
800    resolution_broaden(energies, transmission, params)
801}
802
803/// A tabulated resolution function from Monte Carlo instrument simulation.
804///
805/// Contains reference kernels R(Δt; E_ref) at discrete energies, stored in
806/// TOF-offset space (μs). Kernels are interpolated between reference energies
807/// and converted from TOF to energy space when applied.
808///
809/// ## File Format (VENUS/FTS)
810///
811/// ```text
812/// FTS BL10 case i00dd folded triang FWHM 350 ns PSR   ← header
813/// -----                                                 ← separator
814///    5.00000e-004   0.00000e+000                        ← energy block start
815/// -53.458917835671329 2.051764258257523e-04             ← (tof_offset_μs, weight)
816/// ...
817///                                                       ← blank line separates blocks
818///    1.00000e-003   0.00000e+000                        ← next energy block
819/// ...
820/// ```
821#[derive(Debug, Clone)]
822pub struct TabulatedResolution {
823    /// Reference energies (eV), sorted ascending.
824    ref_energies: Vec<f64>,
825    /// For each reference energy: (tof_offsets_μs, weights) pairs.
826    /// Weights are peak-normalized (max=1.0).
827    kernels: Vec<(Vec<f64>, Vec<f64>)>,
828    /// Flight path length in meters (needed for TOF↔energy conversion).
829    flight_path_m: f64,
830}
831
832impl TabulatedResolution {
833    /// Reference energies (eV), sorted ascending.
834    pub fn ref_energies(&self) -> &[f64] {
835        &self.ref_energies
836    }
837
838    /// For each reference energy: (tof_offsets_μs, weights) pairs.
839    /// Weights are peak-normalized (max=1.0).
840    pub fn kernels(&self) -> &[(Vec<f64>, Vec<f64>)] {
841        &self.kernels
842    }
843
844    /// Flight path length in meters (needed for TOF↔energy conversion).
845    pub fn flight_path_m(&self) -> f64 {
846        self.flight_path_m
847    }
848}
849
850/// Resolution function: either analytical Gaussian or tabulated from Monte Carlo.
851///
852/// The `Tabulated` variant wraps an `Arc` so that cloning (e.g., per-pixel in
853/// spatial mapping) is a cheap reference-count bump rather than a deep copy.
854#[derive(Debug, Clone)]
855pub enum ResolutionFunction {
856    /// Analytical Gaussian resolution from instrument parameters.
857    Gaussian(ResolutionParams),
858    /// Tabulated resolution from Monte Carlo instrument simulation.
859    Tabulated(Arc<TabulatedResolution>),
860}
861
862impl TabulatedResolution {
863    /// Parse a VENUS/FTS resolution file.
864    ///
865    /// # Arguments
866    /// * `text` — File contents as a string.
867    /// * `flight_path_m` — Flight path length in meters.
868    pub fn from_text(text: &str, flight_path_m: f64) -> Result<Self, ResolutionParseError> {
869        let mut lines = text.lines();
870
871        // Skip header and separator
872        let _header = lines
873            .next()
874            .ok_or(ResolutionParseError::InvalidFormat("Empty file".into()))?;
875        let _sep = lines.next().ok_or(ResolutionParseError::InvalidFormat(
876            "Missing separator".into(),
877        ))?;
878
879        let mut ref_energies = Vec::new();
880        let mut kernels = Vec::new();
881        let mut current_energy: Option<f64> = None;
882        let mut current_offsets: Vec<f64> = Vec::new();
883        let mut current_weights: Vec<f64> = Vec::new();
884
885        for line in lines {
886            let trimmed = line.trim();
887            if trimmed.is_empty() {
888                // End of current block
889                if let Some(e) = current_energy.take() {
890                    ref_energies.push(e);
891                    kernels.push((
892                        std::mem::take(&mut current_offsets),
893                        std::mem::take(&mut current_weights),
894                    ));
895                }
896                continue;
897            }
898
899            let parts: Vec<&str> = trimmed.split_whitespace().collect();
900            if parts.len() != 2 {
901                if current_energy.is_some() {
902                    return Err(ResolutionParseError::InvalidFormat(format!(
903                        "Expected 2 columns inside energy block, got {}: '{}'",
904                        parts.len(),
905                        trimmed
906                    )));
907                }
908                // Outside a data block (e.g. extra header lines) — skip
909                continue;
910            }
911
912            let x: f64 = parts[0].parse().map_err(|_| {
913                ResolutionParseError::InvalidFormat(format!("Cannot parse float: '{}'", parts[0]))
914            })?;
915            let y: f64 = parts[1].parse().map_err(|_| {
916                ResolutionParseError::InvalidFormat(format!("Cannot parse float: '{}'", parts[1]))
917            })?;
918
919            if current_energy.is_none() {
920                // First line of block: energy + 0.0 marker
921                current_energy = Some(x);
922            } else {
923                current_offsets.push(x);
924                current_weights.push(y);
925            }
926        }
927
928        // Flush last block
929        if let Some(e) = current_energy.take() {
930            ref_energies.push(e);
931            kernels.push((current_offsets, current_weights));
932        }
933
934        if ref_energies.is_empty() {
935            return Err(ResolutionParseError::InvalidFormat(
936                "No energy blocks found".into(),
937            ));
938        }
939
940        // Validate strictly ascending reference energies
941        for i in 1..ref_energies.len() {
942            if ref_energies[i] <= ref_energies[i - 1] {
943                return Err(ResolutionParseError::InvalidFormat(format!(
944                    "Reference energies must be strictly ascending, but E[{}]={} <= E[{}]={}",
945                    i,
946                    ref_energies[i],
947                    i - 1,
948                    ref_energies[i - 1],
949                )));
950            }
951        }
952
953        Ok(TabulatedResolution {
954            ref_energies,
955            kernels,
956            flight_path_m,
957        })
958    }
959
960    /// Parse a VENUS/FTS resolution file from disk.
961    pub fn from_file(path: &str, flight_path_m: f64) -> Result<Self, ResolutionParseError> {
962        let text = std::fs::read_to_string(path)
963            .map_err(|e| ResolutionParseError::IoError(format!("Cannot read '{}': {}", path, e)))?;
964        Self::from_text(&text, flight_path_m)
965    }
966
967    /// Apply tabulated resolution broadening to a spectrum.
968    ///
969    /// For each energy point:
970    /// 1. Find bracketing reference energies and interpolate kernel (log-space)
971    /// 2. Convert TOF offsets to energy offsets using exact TOF↔energy relation
972    /// 3. Convolve spectrum with interpolated kernel (trapezoidal integration)
973    ///
974    /// # Errors
975    /// Returns [`ResolutionError::LengthMismatch`] if the arrays differ in
976    /// length, or [`ResolutionError::UnsortedEnergies`] if the energy grid is
977    /// not sorted in non-descending order.
978    pub fn broaden(&self, energies: &[f64], spectrum: &[f64]) -> Result<Vec<f64>, ResolutionError> {
979        validate_inputs(energies, spectrum)?;
980        Ok(self.broaden_presorted(energies, spectrum))
981    }
982
983    /// Tabulated resolution broadening assuming the energy grid is already
984    /// validated (sorted ascending, same length as spectrum).
985    pub(crate) fn broaden_presorted(&self, energies: &[f64], spectrum: &[f64]) -> Vec<f64> {
986        let n = energies.len();
987        if n == 0 {
988            return vec![];
989        }
990
991        let mut result = vec![0.0f64; n];
992
993        for i in 0..n {
994            let e = energies[i];
995            if e <= 0.0 {
996                result[i] = spectrum[i];
997                continue;
998            }
999
1000            // TOF at this energy: t = TOF_FACTOR * L / sqrt(E)
1001            let tof_center = TOF_FACTOR * self.flight_path_m / e.sqrt();
1002
1003            // Compute interpolated kernel on the fly to avoid O(N * kernel_len) memory
1004            let (offsets, weights) = self.interpolated_kernel(e);
1005
1006            // Convolve: for each kernel point, find the energy corresponding to
1007            // tof_center + dt_offset, then interpolate spectrum at that energy.
1008            let mut sum = 0.0;
1009            let mut norm = 0.0;
1010
1011            for k in 0..offsets.len() {
1012                let dt = offsets[k];
1013                let w = weights[k];
1014                if w <= 0.0 {
1015                    continue;
1016                }
1017
1018                let tof_prime = tof_center + dt;
1019                if tof_prime <= 0.0 {
1020                    continue;
1021                }
1022
1023                // Convert TOF to energy: E' = (TOF_FACTOR * L / t')^2
1024                let e_prime = (TOF_FACTOR * self.flight_path_m / tof_prime).powi(2);
1025
1026                // Interpolate spectrum at e_prime; skip if outside the grid
1027                let s = match interp_spectrum(energies, spectrum, e_prime) {
1028                    Some(v) => v,
1029                    None => continue,
1030                };
1031
1032                // Trapezoidal weight for the TOF integral
1033                let dt_width = if k > 0 && k < offsets.len() - 1 {
1034                    (offsets[k + 1] - offsets[k - 1]) * 0.5
1035                } else if k == 0 && offsets.len() > 1 {
1036                    offsets[1] - offsets[0]
1037                } else if k == offsets.len() - 1 && offsets.len() > 1 {
1038                    offsets[k] - offsets[k - 1]
1039                } else {
1040                    1.0
1041                };
1042
1043                let weight = w * dt_width.abs();
1044                sum += weight * s;
1045                norm += weight;
1046            }
1047
1048            result[i] = if norm > DIVISION_FLOOR {
1049                sum / norm
1050            } else {
1051                spectrum[i]
1052            };
1053        }
1054
1055        result
1056    }
1057
1058    /// Interpolate kernel at an arbitrary energy using log-space linear interpolation
1059    /// between the two nearest reference energies.
1060    ///
1061    /// `ref_energies` is validated as strictly ascending by `from_text()` /
1062    /// `from_file()` at construction time, so no per-call sort check is needed.
1063    fn interpolated_kernel(&self, energy: f64) -> (Vec<f64>, Vec<f64>) {
1064        debug_assert!(
1065            self.ref_energies.windows(2).all(|w| w[0] < w[1]),
1066            "ref_energies must be strictly ascending (invariant broken)"
1067        );
1068        let n_ref = self.ref_energies.len();
1069
1070        // Clamp to nearest reference if outside range
1071        if energy <= self.ref_energies[0] || n_ref == 1 {
1072            return self.kernels[0].clone();
1073        }
1074        if energy >= self.ref_energies[n_ref - 1] {
1075            return self.kernels[n_ref - 1].clone();
1076        }
1077
1078        // Find bracketing indices
1079        let pos = self.ref_energies.partition_point(|&e| e < energy);
1080        let idx = if pos == 0 {
1081            0
1082        } else {
1083            (pos - 1).min(n_ref - 2)
1084        };
1085
1086        let e_lo = self.ref_energies[idx];
1087        let e_hi = self.ref_energies[idx + 1];
1088
1089        // Log-space interpolation fraction
1090        let frac = (energy.ln() - e_lo.ln()) / (e_hi.ln() - e_lo.ln());
1091
1092        let (off_lo, w_lo) = &self.kernels[idx];
1093        let (off_hi, w_hi) = &self.kernels[idx + 1];
1094
1095        // If both kernels have the same number of points, interpolate element-wise
1096        if off_lo.len() == off_hi.len() {
1097            let offsets: Vec<f64> = off_lo
1098                .iter()
1099                .zip(off_hi.iter())
1100                .map(|(&a, &b)| a + frac * (b - a))
1101                .collect();
1102            let weights: Vec<f64> = w_lo
1103                .iter()
1104                .zip(w_hi.iter())
1105                .map(|(&a, &b)| a + frac * (b - a))
1106                .collect();
1107            (offsets, weights)
1108        } else {
1109            // Different sizes: use nearest
1110            if frac < 0.5 {
1111                self.kernels[idx].clone()
1112            } else {
1113                self.kernels[idx + 1].clone()
1114            }
1115        }
1116    }
1117}
1118
1119/// Linear interpolation of spectrum at an arbitrary energy.
1120///
1121/// Returns `None` if `e` is outside the grid range, so callers can
1122/// exclude off-grid kernel samples instead of clamping to boundary values.
1123fn interp_spectrum(energies: &[f64], spectrum: &[f64], e: f64) -> Option<f64> {
1124    let n = energies.len();
1125    if n == 0 {
1126        return None;
1127    }
1128    if e < energies[0] || e > energies[n - 1] {
1129        return None;
1130    }
1131
1132    // Binary search for bracketing index
1133    let mut lo = 0;
1134    let mut hi = n - 1;
1135    while hi - lo > 1 {
1136        let mid = (lo + hi) / 2;
1137        if energies[mid] <= e {
1138            lo = mid;
1139        } else {
1140            hi = mid;
1141        }
1142    }
1143
1144    // Guard: if energies[hi] == energies[lo] (duplicate grid points or
1145    // single-point grid where lo==hi), the denominator is zero.  Return the
1146    // value at the lower bracket to avoid NaN.
1147    let span = energies[hi] - energies[lo];
1148    if span.abs() < NEAR_ZERO_FLOOR {
1149        return Some(spectrum[lo]);
1150    }
1151    let frac = (e - energies[lo]) / span;
1152    Some(spectrum[lo] + frac * (spectrum[hi] - spectrum[lo]))
1153}
1154
1155/// Apply resolution broadening using either Gaussian or tabulated kernel.
1156///
1157/// # Errors
1158/// Returns [`ResolutionError`] if the energy grid is unsorted or array
1159/// lengths do not match.
1160pub fn apply_resolution(
1161    energies: &[f64],
1162    spectrum: &[f64],
1163    resolution: &ResolutionFunction,
1164) -> Result<Vec<f64>, ResolutionError> {
1165    match resolution {
1166        ResolutionFunction::Gaussian(params) => resolution_broaden(energies, spectrum, params),
1167        ResolutionFunction::Tabulated(tab) => tab.broaden(energies, spectrum),
1168    }
1169}
1170
1171/// Apply resolution broadening assuming the energy grid is already validated
1172/// (sorted ascending, same length as spectrum).
1173///
1174/// Used by `transmission.rs` to avoid redundant O(N) sort checks when
1175/// broadening multiple isotopes on the same pre-validated energy grid.
1176pub(crate) fn apply_resolution_presorted(
1177    energies: &[f64],
1178    spectrum: &[f64],
1179    resolution: &ResolutionFunction,
1180) -> Vec<f64> {
1181    match resolution {
1182        ResolutionFunction::Gaussian(params) => {
1183            resolution_broaden_presorted(energies, spectrum, params)
1184        }
1185        ResolutionFunction::Tabulated(tab) => tab.broaden_presorted(energies, spectrum),
1186    }
1187}
1188
1189/// Errors from resolution file parsing.
1190#[derive(Debug)]
1191pub enum ResolutionParseError {
1192    InvalidFormat(String),
1193    IoError(String),
1194}
1195
1196impl fmt::Display for ResolutionParseError {
1197    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1198        match self {
1199            Self::InvalidFormat(msg) => write!(f, "Invalid resolution file format: {}", msg),
1200            Self::IoError(msg) => write!(f, "I/O error: {}", msg),
1201        }
1202    }
1203}
1204
1205impl std::error::Error for ResolutionParseError {}
1206
1207#[cfg(test)]
1208mod tests {
1209    use super::*;
1210    use nereids_core::constants;
1211
1212    #[test]
1213    fn test_tof_factor_consistency() {
1214        // Verify our TOF_FACTOR matches the constants module.
1215        let e = 10.0; // eV
1216        let l = 25.0; // meters
1217        let tof_constants = constants::energy_to_tof(e, l);
1218        let tof_ours = TOF_FACTOR * l / e.sqrt();
1219        let rel_diff = (tof_constants - tof_ours).abs() / tof_constants;
1220        assert!(
1221            rel_diff < 1e-10,
1222            "TOF mismatch: constants={}, ours={}, diff={:.4}%",
1223            tof_constants,
1224            tof_ours,
1225            rel_diff * 100.0
1226        );
1227    }
1228
1229    #[test]
1230    fn test_resolution_width_scaling() {
1231        let params = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
1232
1233        // Resolution width should increase with energy.
1234        let w1 = params.gaussian_width(1.0);
1235        let w10 = params.gaussian_width(10.0);
1236        let w100 = params.gaussian_width(100.0);
1237
1238        assert!(w10 > w1, "Width should increase with energy");
1239        assert!(w100 > w10, "Width should increase with energy");
1240
1241        // At low energies, timing dominates: ΔE ∝ E^(3/2)
1242        // At high energies, path dominates: ΔE ∝ E
1243        // The ratio ΔE(10)/ΔE(1) should be between 10 and 31.6 (= 10^1.5)
1244        let ratio = w10 / w1;
1245        assert!(
1246            ratio > 5.0 && ratio < 40.0,
1247            "Width ratio = {}, expected between 10 and 31.6",
1248            ratio
1249        );
1250    }
1251
1252    #[test]
1253    fn test_zero_width_passthrough() {
1254        // If resolution parameters are zero, output should equal input.
1255        let energies = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1256        let xs = vec![10.0, 20.0, 30.0, 20.0, 10.0];
1257        let params = ResolutionParams::new(25.0, 0.0, 0.0, 0.0).unwrap();
1258        let broadened = resolution_broaden(&energies, &xs, &params).unwrap();
1259        assert_eq!(broadened, xs);
1260    }
1261
1262    #[test]
1263    fn test_broadening_reduces_peak() {
1264        // Resolution broadening should reduce peak heights and fill valleys.
1265        let n = 1001;
1266        let energies: Vec<f64> = (0..n).map(|i| 5.0 + (i as f64) * 0.01).collect();
1267        let center = 10.0;
1268        let gamma: f64 = 0.1; // Resonance width
1269        let xs: Vec<f64> = energies
1270            .iter()
1271            .map(|&e| {
1272                let de = e - center;
1273                1000.0 * (gamma / 2.0).powi(2) / (de * de + (gamma / 2.0).powi(2))
1274            })
1275            .collect();
1276
1277        let params = ResolutionParams::new(25.0, 5.0, 0.01, 0.0).unwrap();
1278        let broadened = resolution_broaden(&energies, &xs, &params).unwrap();
1279
1280        let orig_peak = xs.iter().cloned().fold(0.0_f64, f64::max);
1281        let broad_peak = broadened.iter().cloned().fold(0.0_f64, f64::max);
1282
1283        assert!(
1284            broad_peak < orig_peak,
1285            "Broadened peak ({}) should be < original ({})",
1286            broad_peak,
1287            orig_peak
1288        );
1289        assert!(
1290            broad_peak > 1.0,
1291            "Broadened peak ({}) should still be substantial",
1292            broad_peak
1293        );
1294    }
1295
1296    #[test]
1297    fn test_broadening_conserves_area() {
1298        // Resolution broadening should approximately conserve the area
1299        // under the cross-section curve.
1300        let n = 2001;
1301        let energies: Vec<f64> = (0..n).map(|i| 5.0 + (i as f64) * 0.005).collect();
1302        let center = 10.0;
1303        let gamma: f64 = 0.5;
1304        let xs: Vec<f64> = energies
1305            .iter()
1306            .map(|&e| {
1307                let de = e - center;
1308                1000.0 * (gamma / 2.0).powi(2) / (de * de + (gamma / 2.0).powi(2))
1309            })
1310            .collect();
1311
1312        let params = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
1313        let broadened = resolution_broaden(&energies, &xs, &params).unwrap();
1314
1315        // Trapezoidal area
1316        let area_orig: f64 = (0..n - 1)
1317            .map(|i| 0.5 * (xs[i] + xs[i + 1]) * (energies[i + 1] - energies[i]))
1318            .sum();
1319        let area_broad: f64 = (0..n - 1)
1320            .map(|i| 0.5 * (broadened[i] + broadened[i + 1]) * (energies[i + 1] - energies[i]))
1321            .sum();
1322
1323        let rel_diff = (area_orig - area_broad).abs() / area_orig;
1324        assert!(
1325            rel_diff < 0.02,
1326            "Area not conserved: orig={:.2}, broad={:.2}, rel_diff={:.4}",
1327            area_orig,
1328            area_broad,
1329            rel_diff
1330        );
1331    }
1332
1333    #[test]
1334    fn test_gaussian_broadening_analytical() {
1335        // Broadening a Gaussian with a Gaussian should give a wider Gaussian.
1336        //
1337        // Input:  exp(-x²/(2σ₁²)) with σ₁ = 0.5 eV (standard Gaussian form)
1338        // Kernel: exp(-x²/W²) with W = 0.3 eV → std dev σ₂ = W/√2 = 0.2121 eV
1339        // Output: Gaussian with σ_out = √(σ₁² + σ₂²) = √(0.25 + 0.045) = 0.543 eV
1340        //
1341        // Note: kernel width varies slightly with energy (σ_E ∝ E for the
1342        // path-length contribution), so we allow ~5% tolerance.
1343        let n = 2001;
1344        let center = 10.0;
1345        let sigma_input = 0.5; // eV (standard deviation)
1346        let energies: Vec<f64> = (0..n).map(|i| 5.0 + (i as f64) * 0.005).collect();
1347        let xs: Vec<f64> = energies
1348            .iter()
1349            .map(|&e| {
1350                let de = e - center;
1351                1000.0 * (-de * de / (2.0 * sigma_input * sigma_input)).exp()
1352            })
1353            .collect();
1354
1355        // Set delta_l such that W = gaussian_width(E=10) ≈ 0.3 eV.
1356        // W = 2·ΔL·E/L, so ΔL = W·L/(2E) = 0.3×25/(20) = 0.375 m
1357        let w_kernel = 0.3; // Kernel parameter W (exp(-x²/W²))
1358        let params =
1359            ResolutionParams::new(25.0, 0.0, w_kernel * 25.0 / (2.0 * center), 0.0).unwrap();
1360
1361        // Verify kernel W at center energy
1362        let w_at_center = params.gaussian_width(center);
1363        assert!(
1364            (w_at_center - w_kernel).abs() / w_kernel < 0.01,
1365            "Kernel W at center: {}, expected {}",
1366            w_at_center,
1367            w_kernel
1368        );
1369
1370        let broadened = resolution_broaden(&energies, &xs, &params).unwrap();
1371
1372        // Kernel std dev = W/√2
1373        let sigma_kernel = w_kernel / 2.0_f64.sqrt();
1374        let sigma_expected = (sigma_input * sigma_input + sigma_kernel * sigma_kernel).sqrt();
1375        let fwhm_expected = 2.0 * (2.0_f64.ln() * 2.0).sqrt() * sigma_expected;
1376
1377        // Measure FWHM from the broadened output
1378        let peak_idx = broadened
1379            .iter()
1380            .enumerate()
1381            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
1382            .unwrap()
1383            .0;
1384        let peak_val = broadened[peak_idx];
1385        let half_max = peak_val / 2.0;
1386
1387        let mut left_hm = energies[0];
1388        for i in (0..peak_idx).rev() {
1389            if broadened[i] < half_max {
1390                let t = (half_max - broadened[i]) / (broadened[i + 1] - broadened[i]);
1391                left_hm = energies[i] + t * (energies[i + 1] - energies[i]);
1392                break;
1393            }
1394        }
1395        let mut right_hm = energies[n - 1];
1396        for i in peak_idx..n - 1 {
1397            if broadened[i + 1] < half_max {
1398                let t = (half_max - broadened[i]) / (broadened[i + 1] - broadened[i]);
1399                right_hm = energies[i] + t * (energies[i + 1] - energies[i]);
1400                break;
1401            }
1402        }
1403
1404        let fwhm_measured = right_hm - left_hm;
1405        let rel_err = (fwhm_measured - fwhm_expected).abs() / fwhm_expected;
1406
1407        assert!(
1408            rel_err < 0.05,
1409            "FWHM: measured={:.4}, expected={:.4}, rel_err={:.2}%",
1410            fwhm_measured,
1411            fwhm_expected,
1412            rel_err * 100.0
1413        );
1414    }
1415
1416    #[test]
1417    fn test_venus_typical_resolution() {
1418        // Verify resolution width for typical VENUS parameters.
1419        // VENUS: L ≈ 25 m, Δt ≈ 10 μs (pulsed source), ΔL ≈ 0.01 m
1420        let params = ResolutionParams::new(25.0, 10.0, 0.01, 0.0).unwrap();
1421
1422        // At 1 eV: ΔE/E should be small (good resolution)
1423        let de_1 = params.gaussian_width(1.0);
1424        let de_over_e_1 = de_1 / 1.0;
1425        assert!(
1426            de_over_e_1 < 0.05,
1427            "ΔE/E at 1 eV = {:.4}, should be < 5%",
1428            de_over_e_1
1429        );
1430
1431        // At 100 eV: resolution degrades
1432        let de_100 = params.gaussian_width(100.0);
1433        let de_over_e_100 = de_100 / 100.0;
1434        assert!(
1435            de_over_e_100 > de_over_e_1,
1436            "Resolution should degrade at higher energies"
1437        );
1438    }
1439
1440    #[test]
1441    fn test_unsorted_energies_returns_error() {
1442        let energies = vec![1.0, 3.0, 2.0, 4.0]; // not sorted
1443        let xs = vec![10.0, 30.0, 20.0, 40.0];
1444        let params = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
1445        let result = resolution_broaden(&energies, &xs, &params);
1446        assert!(result.is_err());
1447        assert!(matches!(
1448            result.unwrap_err(),
1449            ResolutionError::UnsortedEnergies
1450        ));
1451    }
1452
1453    #[test]
1454    fn test_length_mismatch_returns_error() {
1455        let energies = vec![1.0, 2.0, 3.0];
1456        let xs = vec![10.0, 20.0]; // wrong length
1457        let params = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
1458        let result = resolution_broaden(&energies, &xs, &params);
1459        assert!(result.is_err());
1460        assert!(matches!(
1461            result.unwrap_err(),
1462            ResolutionError::LengthMismatch {
1463                energies: 3,
1464                data: 2
1465            }
1466        ));
1467    }
1468
1469    // --- ResolutionParams validation tests ---
1470
1471    #[test]
1472    fn test_resolution_params_valid() {
1473        let p = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
1474        assert!((p.flight_path_m() - 25.0).abs() < 1e-15);
1475        assert!((p.delta_t_us() - 1.0).abs() < 1e-15);
1476        assert!((p.delta_l_m() - 0.01).abs() < 1e-15);
1477    }
1478
1479    #[test]
1480    fn test_resolution_params_rejects_zero_flight_path() {
1481        let err = ResolutionParams::new(0.0, 1.0, 0.01, 0.0).unwrap_err();
1482        assert_eq!(err, ResolutionParamsError::InvalidFlightPath(0.0));
1483    }
1484
1485    #[test]
1486    fn test_resolution_params_rejects_negative_flight_path() {
1487        let err = ResolutionParams::new(-1.0, 1.0, 0.01, 0.0).unwrap_err();
1488        assert_eq!(err, ResolutionParamsError::InvalidFlightPath(-1.0));
1489    }
1490
1491    #[test]
1492    fn test_resolution_params_rejects_nan_flight_path() {
1493        let err = ResolutionParams::new(f64::NAN, 1.0, 0.01, 0.0).unwrap_err();
1494        assert!(matches!(err, ResolutionParamsError::InvalidFlightPath(_)));
1495    }
1496
1497    #[test]
1498    fn test_resolution_params_rejects_negative_delta_t() {
1499        let err = ResolutionParams::new(25.0, -1.0, 0.01, 0.0).unwrap_err();
1500        assert_eq!(err, ResolutionParamsError::InvalidDeltaT(-1.0));
1501    }
1502
1503    #[test]
1504    fn test_resolution_params_rejects_nan_delta_t() {
1505        let err = ResolutionParams::new(25.0, f64::NAN, 0.01, 0.0).unwrap_err();
1506        assert!(matches!(err, ResolutionParamsError::InvalidDeltaT(_)));
1507    }
1508
1509    #[test]
1510    fn test_resolution_params_rejects_negative_delta_l() {
1511        let err = ResolutionParams::new(25.0, 1.0, -0.01, 0.0).unwrap_err();
1512        assert_eq!(err, ResolutionParamsError::InvalidDeltaL(-0.01));
1513    }
1514
1515    #[test]
1516    fn test_resolution_params_rejects_inf_delta_l() {
1517        let err = ResolutionParams::new(25.0, 1.0, f64::INFINITY, 0.0).unwrap_err();
1518        assert!(matches!(err, ResolutionParamsError::InvalidDeltaL(_)));
1519    }
1520
1521    #[test]
1522    fn test_resolution_params_rejects_negative_delta_e() {
1523        let err = ResolutionParams::new(25.0, 1.0, 0.01, -0.05).unwrap_err();
1524        assert_eq!(err, ResolutionParamsError::InvalidDeltaE(-0.05));
1525    }
1526
1527    #[test]
1528    fn test_resolution_params_rejects_nan_delta_e() {
1529        let err = ResolutionParams::new(25.0, 1.0, 0.01, f64::NAN).unwrap_err();
1530        assert!(matches!(err, ResolutionParamsError::InvalidDeltaE(_)));
1531    }
1532
1533    #[test]
1534    fn test_resolution_params_accepts_zero_delta_e() {
1535        let p = ResolutionParams::new(25.0, 1.0, 0.01, 0.0).unwrap();
1536        assert!((p.delta_e_us() - 0.0).abs() < 1e-15);
1537        assert!(!p.has_exponential_tail());
1538    }
1539}