Skip to main content

nereids_endf/
resonance.rs

1//! Resonance parameter data structures.
2//!
3//! These types represent parsed ENDF-6 File 2 resonance data, organized
4//! following the structure in SAMMY's `SammyRMatrixParameters.h`.
5//!
6//! ## SAMMY Reference
7//! - `sammy/external/openScale/repo/packages/ScaleUtils/EndfLib/RMatResonanceParam.h`
8//! - `sammy/src/endf/SammyRMatrixParameters.h`
9
10use nereids_core::types::Isotope;
11use serde::{Deserialize, Serialize};
12
13// ─── ENDF TAB1: one-dimensional interpolation table ──────────────────────────
14//
15// TAB1 records encode a piecewise function y(x) with up to 5 interpolation laws
16// (ENDF INT codes 1–5).  Used here for the energy-dependent scattering radius
17// AP(E) when NRO=1.
18//
19// Reference: ENDF-6 Formats Manual §0.5 (TAB1 record type)
20
21/// One-dimensional interpolation table (ENDF TAB1 record).
22///
23/// Stores piecewise-interpolated y(x) data.  Multiple interpolation regions
24/// are supported via ENDF NBT/INT boundary pairs.
25///
26/// Interpolation law codes (ENDF INT), per ENDF-6 Formats Manual §0.5:
27/// - 1: Histogram (y constant = y_left)
28/// - 2: Linear-linear
29/// - 3: Log in x, linear in y  (y linear in ln(x))
30/// - 4: Linear in x, log in y  (ln(y) linear in x)
31/// - 5: Log-log
32///
33/// Verified against SAMMY OpenScale `CELibrary/Interpolate.h`:
34///   case 3 → `LinByLog` = log-x/linear-y
35///   case 4 → `LogByLin` = linear-x/log-y
36///
37/// Reference: ENDF-6 Formats Manual §0.5; SAMMY OpenScale `CELibrary/Interpolate.h`
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct Tab1 {
40    /// Interpolation region boundaries (NBT, 1-based index of the last point
41    /// in each region).  `boundaries.len() == interp_codes.len()`.
42    pub boundaries: Vec<usize>,
43    /// Interpolation law codes (INT) for each region.
44    pub interp_codes: Vec<u32>,
45    /// Data points as (x, y) pairs, ascending in x; an x given twice in a
46    /// row is a jump, the second value holding from it on.
47    pub points: Vec<(f64, f64)>,
48}
49
50impl Tab1 {
51    /// Evaluate the tabulated function at `x` by piecewise interpolation.
52    ///
53    /// Values outside the tabulated range are clamped to the nearest endpoint
54    /// (no extrapolation).
55    ///
56    /// Log-interpolation modes (INT=3, 4, 5) require strictly positive
57    /// arguments for the logarithm.  If a tabulated value or x-coordinate
58    /// is non-positive where a logarithm would be taken, the function
59    /// transparently falls back to lin-lin interpolation for that interval
60    /// rather than producing NaN or panicking.  In practice, ENDF AP(E)
61    /// tables always have positive x (energy) and positive y (radius in fm),
62    /// so this guard is defensive only.
63    pub fn evaluate(&self, x: f64) -> f64 {
64        let pts = &self.points;
65        if pts.is_empty() {
66            // The parser rejects NP=0, so an empty table indicates a bug in
67            // test-code construction.  Panic in debug builds; return 0.0 in
68            // release to avoid UB.
69            debug_assert!(
70                !pts.is_empty(),
71                "Tab1::evaluate called with empty points table"
72            );
73            return 0.0;
74        }
75        // NaN/±inf: partition_point's comparisons are all false for NaN,
76        // returning index 0, and pts[0 - 1] would underflow.  Clamp to the
77        // nearest finite endpoint instead.
78        if !x.is_finite() {
79            debug_assert!(x.is_finite(), "Tab1::evaluate: non-finite argument {x}");
80            return if x > 0.0 {
81                pts[pts.len() - 1].1
82            } else {
83                pts[0].1
84            };
85        }
86        if x < pts[0].0 || (x == pts[0].0 && pts.get(1).is_none_or(|&(next, _)| next > x)) {
87            return pts[0].1;
88        }
89        if x >= pts[pts.len() - 1].0 {
90            return pts[pts.len() - 1].1;
91        }
92
93        // Binary search: find the first index where pts[i].0 > x.
94        // The interval containing x is [pts[i-1], pts[i]].
95        // Because the outer clamps ensure pts[0].0 < x < pts[last].0,
96        // we are guaranteed x0 < x1 (strict), so (x1 - x0) > 0.
97        let i = pts.partition_point(|(xi, _)| *xi <= x);
98        let (x0, y0) = pts[i - 1];
99        let (x1, y1) = pts[i];
100
101        // Fallback to lin-lin for any interval; used when log guards fire.
102        let lin_lin = || {
103            let t = (x - x0) / (x1 - x0);
104            y0 + t * (y1 - y0)
105        };
106
107        match self.interp_code_for_interval(i - 1) {
108            1 => y0, // histogram: constant left value
109            3 => {
110                // INT=3: y linear in ln(x) — log in x, linear in y.
111                // SAMMY OpenScale: case 3 → LinByLog (requires x0, x1, x > 0).
112                if x0 > 0.0 && x1 > 0.0 && x > 0.0 {
113                    let t = (x.ln() - x0.ln()) / (x1.ln() - x0.ln());
114                    y0 + t * (y1 - y0)
115                } else {
116                    lin_lin()
117                }
118            }
119            4 => {
120                // INT=4: ln(y) linear in x — linear in x, log in y.
121                // SAMMY OpenScale: case 4 → LogByLin (requires y0, y1 > 0).
122                if y0 > 0.0 && y1 > 0.0 {
123                    let t = (x - x0) / (x1 - x0);
124                    (y0.ln() + t * (y1.ln() - y0.ln())).exp()
125                } else {
126                    lin_lin()
127                }
128            }
129            5 => {
130                // log-log; requires x0, x1, x, y0, y1 > 0
131                if x0 > 0.0 && x1 > 0.0 && x > 0.0 && y0 > 0.0 && y1 > 0.0 {
132                    let t = (x.ln() - x0.ln()) / (x1.ln() - x0.ln());
133                    (y0.ln() + t * (y1.ln() - y0.ln())).exp()
134                } else {
135                    lin_lin()
136                }
137            }
138            _ => {
139                // INT=2 (lin-lin) and any unknown code: linear interpolation
140                lin_lin()
141            }
142        }
143    }
144
145    /// Return the ENDF interpolation code for the interval [pts[idx], pts[idx+1]].
146    ///
147    /// ENDF NBT boundaries are 1-based indices of the *last point* in each region.
148    /// Interval `idx` (0-based) belongs to the first region j where `idx + 2 <= NBT[j]`.
149    fn interp_code_for_interval(&self, idx: usize) -> u32 {
150        for (j, &nbt) in self.boundaries.iter().enumerate() {
151            if idx + 2 <= nbt {
152                return self.interp_codes[j];
153            }
154        }
155        self.interp_codes.last().copied().unwrap_or(2)
156    }
157}
158
159/// Resonance formalism flag (ENDF LRF values).
160///
161/// Reference: ENDF-6 Formats Manual, File 2.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
163pub enum ResonanceFormalism {
164    /// Single-Level Breit-Wigner (LRF=1 with SLBW treatment, or SAMMY LRF=-1).
165    SLBW,
166    /// Multi-Level Breit-Wigner (LRF=2).
167    MLBW,
168    /// Reich-Moore (LRF=3). Primary formalism for light and actinide isotopes.
169    ReichMoore,
170    /// R-Matrix Limited (LRF=7), Reich-Moore form (KRM=3). The parameters are
171    /// in [`ResonanceRange::r_matrix_limited`]; a range without them is not
172    /// evaluated.
173    RMatrixLimited,
174    /// Unresolved Resonance Region (LRU=2). Parsed for cursor alignment but not
175    /// evaluated: NEREIDS does not compute URR average cross sections. The
176    /// width-fluctuation correction (a systematically wrong average). Ranges
177    /// tagged `Unresolved` are non-evaluable and resolve to Skip.
178    Unresolved,
179    /// Scattering-radius-only range (LRU=0). ENDF-6 §2.1: the standard stanza
180    /// for materials given a scattering radius but no resonance parameters. It
181    /// carries no resonances, so there is nothing to evaluate — the range is a
182    /// non-evaluable placeholder that resolves to Skip. Captured (rather than
183    /// dropped) so a file whose only range is LRU=0 is rejected with an error
184    /// that names the LRU=0 span instead of misreporting an empty file.
185    ScatteringRadiusOnly,
186}
187
188/// Top-level container for all resonance data parsed from an ENDF file.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct ResonanceData {
191    /// The isotope this data belongs to.
192    pub isotope: Isotope,
193    /// ZA identifier (Z*1000 + A).
194    pub za: u32,
195    /// Atomic weight ratio (mass of target / neutron mass).
196    pub awr: f64,
197    /// File 3's smooth cross sections, added to those of the resonance
198    /// parameters at every energy.
199    pub smooth: SmoothCrossSections,
200    /// Energy ranges containing resonance parameters.
201    pub ranges: Vec<ResonanceRange>,
202}
203
204/// An evaluation's File 3 cross sections in barn, each a function of the
205/// neutron energy in eV, which add to the cross sections its resonance
206/// parameters give (LRP = 1).  `None` for a reaction File 3 does not give.
207#[derive(Debug, Clone, Default, Serialize, Deserialize)]
208pub struct SmoothCrossSections {
209    /// Total, MT = 1.
210    pub total: Option<Tab1>,
211    /// Elastic scattering, MT = 2.
212    pub elastic: Option<Tab1>,
213    /// Radiative capture, MT = 102.
214    pub capture: Option<Tab1>,
215    /// Fission, MT = 18.
216    pub fission: Option<Tab1>,
217}
218
219impl SmoothCrossSections {
220    /// Each table given.
221    pub fn tables(&self) -> impl Iterator<Item = &Tab1> {
222        [&self.total, &self.elastic, &self.capture, &self.fission]
223            .into_iter()
224            .flatten()
225    }
226}
227
228/// A single energy range within the resolved resonance region.
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct ResonanceRange {
231    /// Lower energy bound (eV).
232    pub energy_low: f64,
233    /// Upper energy bound (eV).
234    pub energy_high: f64,
235    /// Resolved (true) or unresolved (false).
236    pub resolved: bool,
237    /// Resonance formalism used in this range.
238    pub formalism: ResonanceFormalism,
239    /// Target spin (I).
240    pub target_spin: f64,
241    /// Scattering radius (fm).
242    ///
243    /// Constant value from the ENDF CONT header AP field.
244    /// When `ap_table` is `Some`, use `scattering_radius_at(energy_ev)` instead
245    /// of reading this field directly — the table provides the energy-dependent
246    /// value, clamping to the nearest endpoint for energies outside the table
247    /// range.  This constant is only used when `ap_table` is `None` (NRO=0).
248    pub scattering_radius: f64,
249    /// NAPS flag: scattering radius calculation control.
250    ///
251    /// NAPS=0: use the channel radius for penetrability/shift calculations.
252    /// NAPS=1: use the scattering radius (AP or AP(E)) for penetrability/shift.
253    /// Reference: ENDF-6 Formats Manual §2.2.1
254    #[serde(default)]
255    pub naps: i32,
256    /// Energy-dependent scattering radius AP(E) (fm), present when NRO=1.
257    ///
258    /// ENDF-6 §2.2.1: when NRO≠0 a TAB1 record immediately follows the range
259    /// CONT header to give AP(E) as a piecewise function.  At each energy the
260    /// table value replaces the constant `scattering_radius` in penetrability,
261    /// shift, and hard-sphere phase calculations.
262    ///
263    /// `None` when the range has NRO=0 (constant AP).
264    ///
265    /// Reference: ENDF-6 Formats Manual §2.2.1; SAMMY `mlb/mmlb1.f90`
266    #[serde(default)]
267    pub ap_table: Option<Tab1>,
268    /// Spin groups for LRF=1/2/3 (L-grouped). Empty for LRF=7 and LRU=2.
269    pub l_groups: Vec<LGroup>,
270    /// R-external (background R-matrix) entries per spin group.
271    ///
272    /// Diagonal, real-valued corrections to the R-matrix that approximate
273    /// the effect of distant (unresolved) resonances.  Keyed by (L, J).
274    ///
275    /// Populated from SAMMY's "R-EXTERNAL PARAMETERS FOLLOW" section.
276    /// Empty for ENDF-only data or SAMMY cases without R-external.
277    ///
278    /// SAMMY Ref: Manual Section II.B.1.d, mpar03.f90 Readrx
279    #[serde(default)]
280    pub r_external: Vec<RExternalEntry>,
281    /// R-Matrix Limited parameters; `Some` only for a parsed LRF=7 range.
282    #[serde(default)]
283    pub r_matrix_limited: Option<RmlRange>,
284}
285
286/// Parameters grouped by orbital angular momentum L.
287///
288/// In ENDF File 2 (LRF=3, Reich-Moore), resonances are grouped by L-value.
289/// Each L-group contains resonances with different J values.
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct LGroup {
292    /// Orbital angular momentum quantum number.
293    pub l: u32,
294    /// Atomic weight ratio for this group.
295    pub awr: f64,
296    /// Channel scattering radius for this L (fm). 0.0 means use the global value.
297    pub apl: f64,
298    /// Q-value for competitive width (eV). Only meaningful for BW formalisms
299    /// (LRF=1/2) where LRX=1; zero otherwise.
300    /// Reference: ENDF-6 Formats Manual §2.2.1.1, L-value CONT record (C2 field).
301    #[serde(default)]
302    pub qx: f64,
303    /// Competitive width flag. LRX=0: no competitive width; LRX=1: competitive
304    /// reaction exists (width = GT - GN - GG - GF). A BW range with LRX ≠ 0 is
305    /// not evaluated: the width's energy dependence needs the l of the exit
306    /// channel (ENDF-6 Formats Manual §D.3.1), which the file does not give.
307    /// Reference: ENDF-6 Formats Manual §2.2.1.1, L-value CONT record (L2 field).
308    #[serde(default)]
309    pub lrx: i32,
310    /// Individual resonances in this L-group.
311    pub resonances: Vec<Resonance>,
312}
313
314/// A single resonance entry.
315///
316/// The meaning of the width fields depends on the formalism:
317///
318/// ## Reich-Moore (LRF=3)
319/// - `gn`: Neutron width Γn (eV)
320/// - `gg`: Radiation (gamma) width Γγ (eV)
321/// - `gfa`: First fission width Γf1 (eV), 0.0 if non-fissile
322/// - `gfb`: Second fission width Γf2 (eV), 0.0 if non-fissile
323///
324/// ## SLBW/MLBW (LRF=1/2)
325/// - `gn`: Neutron width Γn (eV)
326/// - `gg`: Radiation width Γγ (eV)
327/// - `gfa`: Fission width Γf (eV)
328/// - `gfb`: Not used (0.0)
329///
330/// Reference: ENDF-6 Formats Manual, Section 2.2.1
331/// Reference: SAMMY manual, Section 2 (Scattering Theory)
332#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct Resonance {
334    /// Resonance energy (eV).
335    pub energy: f64,
336    /// Total angular momentum J.
337    pub j: f64,
338    /// Neutron width Γn (eV).
339    pub gn: f64,
340    /// Radiation (capture/gamma) width Γγ (eV).
341    pub gg: f64,
342    /// First fission width (eV). Zero for non-fissile isotopes.
343    pub gfa: f64,
344    /// Second fission width (eV). Zero for non-fissile isotopes.
345    pub gfb: f64,
346}
347
348/// The covariance of the energy, neutron width and capture width, in that
349/// order, of each listed resonance of one evaluation: its File 32 (LCOMP=1)
350/// covariance, summed over the blocks that list it, over those parameters.
351#[derive(Clone, PartialEq)]
352pub struct ResonanceCovariance {
353    pub resonances: Vec<[usize; 3]>,
354    /// The covariance over the `3 · resonances.len()` parameters, row by row,
355    /// in eV².
356    pub covariance: std::sync::Arc<[f64]>,
357}
358
359impl std::fmt::Debug for ResonanceCovariance {
360    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
361        write!(f, "ResonanceCovariance({:?})", self.resonances)
362    }
363}
364
365// ─── R-External (Background R-Matrix) ─────────────────────────────────────────
366
367/// R-external (background R-matrix) parameters for a single spin group channel.
368///
369/// Parameterizes smooth R-matrix contribution from distant (unresolved)
370/// resonances.  The background R-matrix is diagonal and real-valued,
371/// parameterized as a logarithmic polynomial in energy.
372///
373/// ## Formula
374/// ```text
375/// R_ext(E) = R_con + R_lin·E + R_quad·E²
376///          + s_lin·(E_up − E_low)
377///          − (s_con + s_lin·E)·ln[(E_up − E) / (E − E_low)]
378/// ```
379///
380/// SAMMY Ref: Manual Section II.B.1.d, mcro2.f90 lines 180-193
381#[derive(Debug, Clone, Default, Serialize, Deserialize)]
382pub struct RExternalEntry {
383    /// Orbital angular momentum L of the spin group.
384    pub l: u32,
385    /// Total angular momentum J (signed, per SAMMY convention).
386    pub j: f64,
387    /// Lower energy bound (eV).
388    pub e_low: f64,
389    /// Upper energy bound (eV).
390    pub e_up: f64,
391    /// Constant term in R-matrix polynomial.
392    pub r_con: f64,
393    /// Linear coefficient (eV⁻¹).
394    pub r_lin: f64,
395    /// Constant logarithmic coefficient.
396    pub s_con: f64,
397    /// Linear logarithmic coefficient (eV⁻¹).
398    pub s_lin: f64,
399    /// Quadratic coefficient (eV⁻²).
400    pub r_quad: f64,
401}
402
403impl RExternalEntry {
404    /// Evaluate R_ext(E) at the given energy.
405    ///
406    /// The polynomial part (`r_con + r_lin·E + r_quad·E²`) applies at all
407    /// energies.  The logarithmic terms are only added when `E` is strictly
408    /// inside `(e_low, e_up)`.
409    ///
410    /// SAMMY Ref: mcro2.f90 Setr_Cro, lines 180-193
411    pub fn evaluate(&self, energy_ev: f64) -> f64 {
412        let e = energy_ev;
413        let mut r = self.r_con + self.r_lin * e + self.r_quad * e * e;
414
415        let e_up_diff = self.e_up - e;
416        let e_low_diff = e - self.e_low;
417        if e_up_diff > 0.0 && e_low_diff > 0.0 {
418            let log_val = (e_up_diff / e_low_diff).ln();
419            r -= (self.s_con + self.s_lin * e) * log_val;
420            r += self.s_lin * (self.e_up - self.e_low);
421        }
422
423        r
424    }
425}
426
427// ─── R-Matrix Limited (LRF=7) ─────────────────────────────────────────────────
428
429/// Parameters of one R-Matrix Limited (LRF=7) range in the Reich-Moore form (KRM=3).
430///
431/// Widths are channel widths in eV (IFG=0). The photon pair's channel is the
432/// eliminated capture channel; the incident pair is the neutron plus target.
433///
434/// # References
435/// ENDF-6 Formats Manual, ENDF-102 (2023), §2.2.1.6 and Appendix D.1.7.
436#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
437pub struct RmlRange {
438    /// Particle pairs, in file order.
439    pub pairs: Vec<ParticlePair>,
440    /// Index in `pairs` of the incident neutron pair.
441    pub incident_pair: usize,
442    /// Index in `pairs` of the photon pair, whose channel is eliminated.
443    pub photon_pair: usize,
444    /// Spin groups, in file order.
445    pub spin_groups: Vec<RmlSpinGroup>,
446}
447
448impl RmlRange {
449    /// Number of resonances over all spin groups.
450    pub fn resonance_count(&self) -> usize {
451        self.spin_groups.iter().map(|g| g.resonances.len()).sum()
452    }
453}
454
455/// One particle pair of an LRF=7 range.
456#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
457pub struct ParticlePair {
458    /// Mass of the first particle in neutron masses (MA).
459    pub mass_a: f64,
460    /// Mass of the second particle in neutron masses (MB).
461    pub mass_b: f64,
462    /// Charge number of the first particle (ZA).
463    pub charge_a: f64,
464    /// Charge number of the second particle (ZB).
465    pub charge_b: f64,
466    /// Spin of the first particle; a negative sign gives negative parity (IA).
467    pub spin_a: f64,
468    /// Spin of the second particle; a negative sign gives negative parity (IB).
469    pub spin_b: f64,
470    /// Q-value of the pair in eV.
471    pub q_value: f64,
472    /// ENDF reaction type of the pair (MT).
473    pub mt: u32,
474    /// Whether the penetrability is calculated (PNT, with PNT=0 resolved by MT).
475    pub penetrability: bool,
476    /// Whether the shift factor is calculated (SHF=1).
477    pub shift: bool,
478}
479
480impl ParticlePair {
481    /// Lab-frame threshold energy in eV, −Q·(A+1)/A, with A = MB/MA of the
482    /// incident pair `incident`; the pair's channels are open above it.
483    pub fn threshold(&self, incident: &ParticlePair) -> f64 {
484        let mass_ratio = incident.mass_b / incident.mass_a;
485        -self.q_value * (mass_ratio + 1.0) / mass_ratio
486    }
487}
488
489/// One channel of an LRF=7 spin group.
490#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
491pub struct RmlChannel {
492    /// Index in [`RmlRange::pairs`] (PPI − 1).
493    pub pair: usize,
494    /// Orbital angular momentum.
495    pub l: u32,
496    /// Channel spin (SCH).
497    pub channel_spin: f64,
498    /// Boundary condition B (BND); used only when the pair calculates the shift factor.
499    pub boundary: f64,
500    /// Effective radius for the phase shift in fm (APE).
501    pub radius_phase: f64,
502    /// True radius for the penetrability and shift factor in fm (APT).
503    pub radius_true: f64,
504}
505
506/// One spin group (J, π) of an LRF=7 range.
507#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
508pub struct RmlSpinGroup {
509    /// Total angular momentum J (non-negative).
510    pub j: f64,
511    /// Parity, +1 or −1.
512    pub parity: i32,
513    /// Channels, in the order of the resonance widths.
514    pub channels: Vec<RmlChannel>,
515    /// Resonances of the group.
516    pub resonances: Vec<RmlResonance>,
517    /// Background R-function terms (KBK records with LBK=2).
518    pub background: Vec<RmlBackground>,
519}
520
521/// One resonance of an LRF=7 spin group.
522#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
523pub struct RmlResonance {
524    /// Resonance energy in eV.
525    pub energy: f64,
526    /// Width per channel in channel order, in eV; a negative width gives the
527    /// sign of the reduced-width amplitude.
528    pub widths: Vec<f64>,
529}
530
531/// Logarithmic background R-function of one channel (LBK=2).
532#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
533pub struct RmlBackground {
534    /// Index of the channel in [`RmlSpinGroup::channels`].
535    pub channel: usize,
536    /// Lower singularity energy in eV (ED).
537    pub e_down: f64,
538    /// Upper singularity energy in eV (EU).
539    pub e_up: f64,
540    /// Constant term (R0).
541    pub r_con: f64,
542    /// Linear term in eV⁻¹ (R1).
543    pub r_lin: f64,
544    /// Quadratic term in eV⁻² (R2).
545    pub r_quad: f64,
546    /// Constant coefficient of the logarithm (S0).
547    pub s_con: f64,
548    /// Linear coefficient of the logarithm in eV⁻¹ (S1).
549    pub s_lin: f64,
550}
551
552impl RmlBackground {
553    /// Background R-function at the lab energy `energy_ev` in eV.
554    ///
555    /// Defined for `e_down < energy_ev < e_up`; the parser requires the range
556    /// to lie inside that interval.
557    ///
558    /// # References
559    /// ENDF-102 (2023) Eq. (D.77); SAMMY manual ORNL/TM-9179/R8 Eq. (II B1 d.1).
560    pub fn evaluate(&self, energy_ev: f64) -> f64 {
561        let e = energy_ev;
562        let log = ((self.e_up - e) / (e - self.e_down)).ln();
563        self.r_con + self.r_lin * e + self.r_quad * e * e
564            - self.s_lin * (self.e_up - self.e_down)
565            - (self.s_con + self.s_lin * e) * log
566    }
567}
568
569impl ResonanceData {
570    /// Total number of resonances across all ranges and groups.
571    ///
572    /// Counts the resonances of LRF=1/2/3 and LRF=7 ranges. LRU=2 ranges
573    /// contribute 0.
574    ///
575    /// A low count for a given evaluation reflects that evaluation's
576    /// resolved-resonance-region (RRR) extent, **not** a dropped energy range.
577    /// The parser reads every NER range and errors on unconsumed MF2/MT151 data,
578    /// so ranges are never silently discarded. For example, Ta-181 in
579    /// ENDF/B-VIII.0 returns 76 (RRR only to 330 eV, plus an unresolved URR that
580    /// contributes 0 discrete resonances), whereas ENDF/B-VIII.1 extended the RRR
581    /// to 2554 eV and returns 565. See `test_parse_ta181_endf8_0_resonance_count`
582    /// in `parser.rs`, which pins the VIII.0 count as a regression guard.
583    pub fn total_resonance_count(&self) -> usize {
584        self.ranges.iter().map(|r| r.resonance_count()).sum()
585    }
586
587    /// Ranges that are parsed but NOT evaluated (non-evaluable placeholders).
588    ///
589    /// These are the LRU=2 (unresolved) and LRU=0 (scattering-radius-only)
590    /// ranges, resolved LRF=1/2 ranges with a competitive width (LRX=1), and
591    /// resolved ranges without resonances. They contribute exactly zero to
592    /// every cross-section. Any physics computed over their energy span reflects
593    /// only the *other* ranges of the evaluation. Callers that surface data
594    /// to users should warn when this list is non-empty.
595    pub fn unevaluated_ranges(&self) -> Vec<&ResonanceRange> {
596        self.ranges.iter().filter(|r| !r.is_evaluable()).collect()
597    }
598
599    /// Whether any range is a non-evaluable parse-and-skip placeholder.
600    ///
601    /// See [`Self::unevaluated_ranges`].
602    pub fn has_unevaluated_ranges(&self) -> bool {
603        self.ranges.iter().any(|r| !r.is_evaluable())
604    }
605
606    /// Whether at least one range can actually produce non-zero cross-sections.
607    ///
608    /// `false` means every range is a parse-and-skip placeholder (or there are
609    /// no ranges at all): the evaluation would return zero cross-section over
610    /// its full grid (transmission ≡ 1). This is the load-time acceptance
611    /// predicate — the parser rejects such an evaluation, and the project
612    /// loader drops it from the ENDF cache so a stale removed-physics payload
613    /// cannot silently restore as a zero-cross-section isotope.
614    pub fn has_evaluable_range(&self) -> bool {
615        self.ranges.iter().any(|r| r.is_evaluable())
616    }
617}
618
619impl ResonanceRange {
620    /// Scattering radius at a given neutron energy.
621    ///
622    /// Returns the interpolated value from `ap_table` when NRO=1 (energy-dependent
623    /// radius), or the constant `scattering_radius` when NRO=0.
624    ///
625    /// Use this method in all physics calculations that need the channel radius,
626    /// rather than reading `scattering_radius` directly.
627    ///
628    /// # Arguments
629    /// * `energy_ev` — Lab-frame neutron energy in eV.
630    pub fn scattering_radius_at(&self, energy_ev: f64) -> f64 {
631        if let Some(table) = &self.ap_table {
632            table.evaluate(energy_ev)
633        } else {
634            self.scattering_radius
635        }
636    }
637
638    /// Energy and total width, both in eV, of every level of the range: the
639    /// L-grouped resonances of LRF=1/2/3 and the spin-group resonances of LRF=7.
640    pub fn levels(&self) -> impl Iterator<Item = (f64, f64)> + '_ {
641        let grouped = self.l_groups.iter().flat_map(|lg| &lg.resonances).map(|r| {
642            (
643                r.energy,
644                r.gn.abs() + r.gg.abs() + r.gfa.abs() + r.gfb.abs(),
645            )
646        });
647        let rml = self
648            .r_matrix_limited
649            .iter()
650            .flat_map(|rml| &rml.spin_groups)
651            .flat_map(|g| &g.resonances)
652            .map(|r| (r.energy, r.widths.iter().map(|w| w.abs()).sum::<f64>()));
653        grouped.chain(rml)
654    }
655
656    /// Total discrete-resonance count for this range.
657    ///
658    /// Counts the L-grouped resonances of an LRF=1/2/3 range and the
659    /// spin-group resonances of an LRF=7 range. LRU=2 ranges contribute 0.
660    pub fn resonance_count(&self) -> usize {
661        self.l_groups
662            .iter()
663            .map(|lg| lg.resonances.len())
664            .sum::<usize>()
665            + self
666                .r_matrix_limited
667                .as_ref()
668                .map_or(0, RmlRange::resonance_count)
669    }
670
671    /// Can this range actually produce non-zero cross-sections?
672    ///
673    /// Evaluable means a resolved (LRU=1) range with either R-Matrix Limited
674    /// (LRF=7) parameters in at least one spin group, or an SLBW (LRF=1),
675    /// MLBW (LRF=2) or Reich-Moore (LRF=3) formalism with at least one
676    /// resonance-bearing L-group and no competitive width (LRX = 0 in every
677    /// L-group). A resolved LRF=1/2/3 range whose L-groups are all empty is a
678    /// placeholder: it is not evaluated and contributes zero cross-section,
679    /// although SAMMY gives its channels hard-sphere scattering. LRU=2
680    /// (unresolved) and LRU=0 (scattering-radius-only) ranges are not
681    /// evaluated, so they return `false` and contribute zero cross-section
682    /// over their energy span.
683    pub fn is_evaluable(&self) -> bool {
684        if let Some(rml) = &self.r_matrix_limited {
685            return self.resolved
686                && self.formalism == ResonanceFormalism::RMatrixLimited
687                && !rml.spin_groups.is_empty();
688        }
689        self.resolved
690            && matches!(
691                self.formalism,
692                ResonanceFormalism::SLBW
693                    | ResonanceFormalism::MLBW
694                    | ResonanceFormalism::ReichMoore
695            )
696            && self.l_groups.iter().any(|lg| !lg.resonances.is_empty())
697            && self.l_groups.iter().all(|lg| lg.lrx == 0)
698    }
699
700    /// One-line diagnostic for a parse-and-skip placeholder range, e.g.
701    /// `"LRF=7 (R-Matrix Limited) over [1.000000e-5, 1.000000e3] eV"`.
702    ///
703    /// Shared by the parser's no-evaluable-content error, the Python-binding
704    /// `UserWarning`, and the GUI load log so all three surfaces describe a
705    /// skipped range identically. Only meaningful for ranges where
706    /// [`Self::is_evaluable`] is `false`: the resolved-formalism arms label
707    /// the accepted-but-inert shapes, no resonances or a competitive width
708    /// (callers never pass an evaluable range).
709    pub fn skip_description(&self) -> String {
710        let competitive = self.l_groups.iter().any(|lg| lg.lrx != 0);
711        let kind = match self.formalism {
712            ResonanceFormalism::RMatrixLimited => "LRF=7 (R-Matrix Limited)",
713            ResonanceFormalism::Unresolved => "LRU=2 (URR)",
714            ResonanceFormalism::ScatteringRadiusOnly => {
715                "LRU=0 (scattering-radius-only, no resonance parameters)"
716            }
717            // A resolved LRF=1/2/3 range reaches here only when it carries no
718            // resonances (every L-group empty) or a competitive width —
719            // accepted, warn-and-skip.
720            ResonanceFormalism::SLBW if competitive => {
721                "LRF=1 (SLBW) resolved range with a competitive width (LRX=1)"
722            }
723            ResonanceFormalism::MLBW if competitive => {
724                "LRF=2 (MLBW) resolved range with a competitive width (LRX=1)"
725            }
726            ResonanceFormalism::SLBW => "LRF=1 (SLBW) resolved range with no resonances",
727            ResonanceFormalism::MLBW => "LRF=2 (MLBW) resolved range with no resonances",
728            ResonanceFormalism::ReichMoore => {
729                "LRF=3 (Reich-Moore) resolved range with no resonances"
730            }
731        };
732        format!(
733            "{kind} over [{:.6e}, {:.6e}] eV",
734            self.energy_low, self.energy_high
735        )
736    }
737}
738
739/// Group resonances by their total angular momentum J value (test-only).
740///
741/// Returns a vector of `(J, resonances)` pairs. Two J values are considered
742/// equal if they differ by less than [`nereids_core::constants::QUANTUM_NUMBER_EPS`].
743///
744/// Note: The physics crate uses `group_resonances_by_j` (in `reich_moore.rs`)
745/// for cross-section precomputation, which builds per-resonance invariants
746/// directly during grouping. This function is retained for unit-level tests
747/// of the grouping logic itself.
748#[cfg(test)]
749fn group_by_j(resonances: &[Resonance]) -> Vec<(f64, Vec<&Resonance>)> {
750    let mut groups: Vec<(f64, Vec<&Resonance>)> = Vec::new();
751    for res in resonances {
752        let j = res.j;
753        if let Some(group) = groups
754            .iter_mut()
755            .find(|(gj, _)| (*gj - j).abs() < nereids_core::constants::QUANTUM_NUMBER_EPS)
756        {
757            group.1.push(res);
758        } else {
759            groups.push((j, vec![res]));
760        }
761    }
762    groups
763}
764
765impl std::fmt::Display for ResonanceData {
766    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
767        write!(
768            f,
769            "ResonanceData(ZA={}, AWR={:.4}, ranges={}, total_resonances={})",
770            self.za,
771            self.awr,
772            self.ranges.len(),
773            self.total_resonance_count()
774        )
775    }
776}
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781
782    fn make_linlin_table(points: Vec<(f64, f64)>) -> Tab1 {
783        let n = points.len();
784        Tab1 {
785            boundaries: vec![n],
786            interp_codes: vec![2],
787            points,
788        }
789    }
790
791    /// Linear-linear interpolation in the interior of the table.
792    #[test]
793    fn test_tab1_linlin_interior() {
794        let table = make_linlin_table(vec![(1.0, 10.0), (5.0, 30.0), (10.0, 5.0)]);
795        // midpoint of [1,5]: x=3 → 10 + (3-1)/(5-1) * (30-10) = 10 + 0.5*20 = 20
796        let v = table.evaluate(3.0);
797        assert!((v - 20.0).abs() < 1e-10, "lin-lin midpoint, got {v}");
798        // midpoint of [5,10]: x=7.5 → 30 + (7.5-5)/(10-5) * (5-30) = 30 + 0.5*(-25) = 17.5
799        let v2 = table.evaluate(7.5);
800        assert!(
801            (v2 - 17.5).abs() < 1e-10,
802            "lin-lin second interval, got {v2}"
803        );
804    }
805
806    /// Values outside the table range clamp to the boundary value.
807    #[test]
808    fn test_tab1_clamping() {
809        let table = make_linlin_table(vec![(2.0, 5.0), (8.0, 15.0)]);
810        assert_eq!(table.evaluate(0.0), 5.0, "below low bound");
811        assert_eq!(table.evaluate(100.0), 15.0, "above high bound");
812        assert_eq!(table.evaluate(2.0), 5.0, "at low bound");
813        assert_eq!(table.evaluate(8.0), 15.0, "at high bound");
814    }
815
816    #[test]
817    fn a_repeated_energy_is_a_jump_to_its_second_value() {
818        let first = make_linlin_table(vec![(1.0, 0.0), (1.0, 5.0), (2.0, 7.0)]);
819        assert_eq!(first.evaluate(0.5), 0.0);
820        assert_eq!(first.evaluate(1.0), 5.0);
821        assert_eq!(first.evaluate(1.5), 6.0);
822        let inside = make_linlin_table(vec![(0.0, 0.0), (1.0, 1.0), (1.0, 5.0), (2.0, 7.0)]);
823        assert_eq!(inside.evaluate(0.5), 0.5);
824        assert_eq!(inside.evaluate(1.0), 5.0);
825        assert_eq!(inside.evaluate(1.5), 6.0);
826    }
827
828    /// Histogram interpolation (INT=1): y stays constant from left endpoint.
829    #[test]
830    fn test_tab1_histogram() {
831        let table = Tab1 {
832            boundaries: vec![3],
833            interp_codes: vec![1],
834            points: vec![(0.0, 10.0), (5.0, 20.0), (10.0, 30.0)],
835        };
836        assert_eq!(
837            table.evaluate(2.5),
838            10.0,
839            "histogram: should return left value"
840        );
841        assert_eq!(table.evaluate(7.5), 20.0, "histogram: second interval");
842    }
843
844    /// Two-region table: lin-lin for low energies, log-x/lin-y (INT=3) for high.
845    #[test]
846    fn test_tab1_multiregion() {
847        // Region 0 (INT=2, lin-lin): points 0..2  (NBT=2)
848        // Region 1 (INT=3, log in x / linear in y): points 2..4  (NBT=4)
849        // Points: (1,1), (3,3), (10,3), (100,30)
850        let table = Tab1 {
851            boundaries: vec![2, 4],
852            interp_codes: vec![2, 3],
853            points: vec![(1.0, 1.0), (3.0, 3.0), (10.0, 3.0), (100.0, 30.0)],
854        };
855        // Interval 0 ([1,3], INT=2 lin-lin): x=2 → 1 + (2-1)/(3-1) * (3-1) = 2
856        assert!(
857            (table.evaluate(2.0) - 2.0).abs() < 1e-10,
858            "region 0 lin-lin"
859        );
860        // Interval 1 ([3,10], INT=3 log-x/lin-y): x=5.
861        // y0==y1==3.0, so any interpolation mode yields 3.0 regardless.
862        // This verifies the region boundary is crossed correctly and that
863        // x=5 routes to interval 1 (not interval 0 or 2).
864        assert!(
865            (table.evaluate(5.0) - 3.0).abs() < 1e-10,
866            "region 1 INT=3 (constant y segment): x=5 should give 3.0"
867        );
868        // Interval 2 ([10,100], INT=3 log-x/lin-y): x=31.62 ≈ sqrt(10*100) = geometric midpoint.
869        // INT=3: t = ln(x/x0) / ln(x1/x0) = ln(31.62/10) / ln(100/10) = ln(3.162)/ln(10) ≈ 0.5
870        // y = y0 + t*(y1 - y0) = 3 + 0.5*(30 - 3) = 16.5
871        let v = table.evaluate(31.62);
872        assert!(
873            (v - 16.5).abs() < 0.1,
874            "region 2 INT=3 at geometric midpoint: expected 16.5, got {v}"
875        );
876    }
877
878    /// scattering_radius_at falls back to constant when ap_table is None.
879    #[test]
880    fn test_scattering_radius_at_constant() {
881        let range = ResonanceRange {
882            energy_low: 1e-5,
883            energy_high: 1e4,
884            resolved: true,
885            formalism: crate::resonance::ResonanceFormalism::ReichMoore,
886            target_spin: 0.0,
887            scattering_radius: 9.4285,
888            naps: 1,
889            ap_table: None,
890            l_groups: vec![],
891            r_external: vec![],
892            r_matrix_limited: None,
893        };
894        assert_eq!(range.scattering_radius_at(1.0), 9.4285);
895        assert_eq!(range.scattering_radius_at(1000.0), 9.4285);
896    }
897
898    /// `is_evaluable` is content-sensitive: a resolved LRF=1/2/3 range whose
899    /// L-groups are all empty is inert (zero cross-section everywhere,
900    /// potential scattering included, because J-groups derive from the
901    /// resonance list) and must not count as evaluable.
902    #[test]
903    fn test_is_evaluable_requires_resonances() {
904        let mut range = ResonanceRange {
905            energy_low: 1e-5,
906            energy_high: 1e4,
907            resolved: true,
908            formalism: crate::resonance::ResonanceFormalism::MLBW,
909            target_spin: 0.0,
910            scattering_radius: 9.4,
911            naps: 1,
912            ap_table: None,
913            l_groups: vec![LGroup {
914                l: 0,
915                awr: 236.0,
916                apl: 0.0,
917                qx: 0.0,
918                lrx: 0,
919                resonances: vec![],
920            }],
921            r_external: vec![],
922            r_matrix_limited: None,
923        };
924        assert!(!range.is_evaluable(), "all-empty L-groups must be inert");
925        range.l_groups[0].resonances.push(Resonance {
926            energy: 6.674,
927            j: 0.5,
928            gn: 1.5e-3,
929            gg: 2.3e-2,
930            gfa: 0.0,
931            gfb: 0.0,
932        });
933        assert!(
934            range.is_evaluable(),
935            "a resonance-bearing L-group is evaluable"
936        );
937        range.l_groups[0].lrx = 1;
938        assert!(
939            !range.is_evaluable(),
940            "a competitive width needs the exit channel's l, which ENDF does not give"
941        );
942    }
943
944    /// scattering_radius_at interpolates from ap_table when NRO=1.
945    #[test]
946    fn test_scattering_radius_at_energy_dependent() {
947        // AP goes from 8.0 fm at 1 eV to 10.0 fm at 1000 eV (lin-lin).
948        let table = make_linlin_table(vec![(1.0, 8.0), (1000.0, 10.0)]);
949        let range = ResonanceRange {
950            energy_low: 1e-5,
951            energy_high: 1e4,
952            resolved: true,
953            formalism: crate::resonance::ResonanceFormalism::ReichMoore,
954            target_spin: 0.0,
955            scattering_radius: 9.0, // constant fallback (ignored when table is Some)
956            naps: 1,
957            ap_table: Some(table),
958            l_groups: vec![],
959            r_external: vec![],
960            r_matrix_limited: None,
961        };
962        // At 1 eV: 8.0 fm
963        assert!((range.scattering_radius_at(1.0) - 8.0).abs() < 1e-10);
964        // At 1000 eV: 10.0 fm
965        assert!((range.scattering_radius_at(1000.0) - 10.0).abs() < 1e-10);
966        // At 500.5 eV (midpoint): 9.0 fm
967        let mid = range.scattering_radius_at(500.5);
968        assert!((mid - 9.0).abs() < 0.01, "midpoint AP ≈ 9.0, got {mid}");
969    }
970
971    /// Log-guard fallback: if an x-coordinate is non-positive in an INT=3
972    /// (log-x, linear-y) interval, evaluate() falls back to lin-lin.
973    #[test]
974    fn test_tab1_log_guard_nonpositive_x() {
975        // INT=3 (log in x, linear in y) with x0=0.0 — 0.0_f64.ln() = -inf without guard.
976        let table = Tab1 {
977            boundaries: vec![2],
978            interp_codes: vec![3], // log in x, linear in y
979            points: vec![(0.0, 8.0), (10.0, 10.0)],
980        };
981        // x=0.0 is at the left boundary; evaluate() clamps to y=8.0 before interpolation.
982        assert!((table.evaluate(0.0) - 8.0).abs() < 1e-10);
983        // x=5.0 is interior; x0=0.0 triggers the log guard → lin-lin fallback.
984        let result = table.evaluate(5.0);
985        assert!(
986            result.is_finite(),
987            "fallback to lin-lin should give finite result, got {result}"
988        );
989    }
990
991    /// Log-guard fallback: if a y-value is non-positive in an INT=4
992    /// (linear-x, log-y) interval, evaluate() falls back to lin-lin.
993    #[test]
994    fn test_tab1_log_guard_nonpositive_y() {
995        // INT=4 (linear in x, log in y) with y0=0.0 — 0.0_f64.ln() = -inf without guard.
996        let table = Tab1 {
997            boundaries: vec![2],
998            interp_codes: vec![4], // linear in x, log in y
999            points: vec![(1.0, 0.0), (10.0, 1.0)],
1000        };
1001        let result = table.evaluate(5.0);
1002        assert!(
1003            result.is_finite(),
1004            "fallback to lin-lin should give finite result, got {result}"
1005        );
1006    }
1007
1008    /// INT=3 (log in x, linear in y): verify correct formula against analytic values.
1009    #[test]
1010    fn test_tab1_logx_linear_y() {
1011        // Points at x=1 (y=0) and x=100 (y=2.0).
1012        // At x=10: t = ln(10)/ln(100) = 1/2, y = 0 + 0.5*2 = 1.0
1013        let table = Tab1 {
1014            boundaries: vec![2],
1015            interp_codes: vec![3], // log in x, linear in y
1016            points: vec![(1.0, 0.0), (100.0, 2.0)],
1017        };
1018        let y = table.evaluate(10.0);
1019        assert!(
1020            (y - 1.0).abs() < 1e-12,
1021            "INT=3 at geometric midpoint x=10: expected y=1.0, got {y}"
1022        );
1023    }
1024
1025    /// INT=4 (linear in x, log in y): verify correct formula against analytic values.
1026    #[test]
1027    fn test_tab1_linear_x_logy() {
1028        // Points at x=0 (y=1) and x=2 (y=e²).
1029        // At x=1 (midpoint): t=0.5, y = exp(0 + 0.5*2) = exp(1) = e
1030        let e = std::f64::consts::E;
1031        let table = Tab1 {
1032            boundaries: vec![2],
1033            interp_codes: vec![4], // linear in x, log in y
1034            points: vec![(0.0, 1.0), (2.0, e * e)],
1035        };
1036        let y = table.evaluate(1.0);
1037        assert!(
1038            (y - e).abs() < 1e-12,
1039            "INT=4 at midpoint x=1: expected y=e={e:.6}, got {y:.6}"
1040        );
1041    }
1042
1043    #[test]
1044    fn test_group_by_j() {
1045        // Empty input
1046        let groups = group_by_j(&[]);
1047        assert!(groups.is_empty());
1048
1049        // Single resonance
1050        let r1 = Resonance {
1051            energy: 6.67,
1052            j: 0.5,
1053            gn: 0.001,
1054            gg: 0.023,
1055            gfa: 0.0,
1056            gfb: 0.0,
1057        };
1058        let single = [r1.clone()];
1059        let groups = group_by_j(&single);
1060        assert_eq!(groups.len(), 1);
1061        assert_eq!(groups[0].1.len(), 1);
1062
1063        // Multiple J values
1064        let r2 = Resonance {
1065            j: 1.5,
1066            ..r1.clone()
1067        };
1068        let r3 = Resonance {
1069            j: 0.5,
1070            energy: 20.0,
1071            ..r1.clone()
1072        };
1073        let multi = [r1, r2, r3];
1074        let groups = group_by_j(&multi);
1075        assert_eq!(groups.len(), 2); // J=0.5 and J=1.5
1076        // J=0.5 group should have 2 resonances
1077        let j05 = groups
1078            .iter()
1079            .find(|(j, _)| (*j - 0.5).abs() < nereids_core::constants::QUANTUM_NUMBER_EPS)
1080            .unwrap();
1081        assert_eq!(j05.1.len(), 2);
1082    }
1083}
1084
1085/// Synthetic [`ResonanceData`] / [`ResonanceRange`] builders for cross-crate
1086/// tests.  Gated on `#[cfg(any(test, feature = "test-support"))]`: visible to
1087/// in-crate `#[cfg(test)] mod tests` AND to integration tests in sibling crates
1088/// that enable the `test-support` feature in their `[dev-dependencies]`.  Never
1089/// builders into one named API, mirroring
1090/// `nereids_physics::resolution::test_support`.
1091#[cfg(any(test, feature = "test-support"))]
1092pub mod test_support {
1093    use super::{LGroup, Resonance, ResonanceData, ResonanceFormalism, ResonanceRange};
1094    use nereids_core::types::Isotope;
1095
1096    /// Parameters for [`single_resonance`].  No `Default`: every field is
1097    /// required because different absorbing sites used different "defaults",
1098    /// and forcing callers to be explicit prevents silent drift.
1099    pub struct SingleResonanceParams {
1100        pub energy: f64,
1101        pub gamma_n: f64,
1102        pub gamma_g: f64,
1103        pub j: f64,
1104        pub l: u32,
1105        pub awr: f64,
1106        pub target_spin: f64,
1107        pub scattering_radius: f64,
1108    }
1109
1110    // --- Private structural helpers ---
1111    //
1112    // The public fixtures below all build a single `ResonanceRange` with one
1113    // `LGroup` whose body varies only in well-defined ways.  The two private
1114    // helpers below absorb the structural skeleton (resolved/ap_table/
1115    // r_external/qx/lrx/gfa/gfb) so each public helper carries only the
1116    // physically-meaningful parameters.
1117
1118    /// One resolved `ResonanceRange` with a single L-group.  All "structural
1119    /// invariants" (resolved, `ap_table`/`r_external`,
1120    /// `qx`/`lrx`) get the minimal-fixture defaults.
1121    #[allow(clippy::too_many_arguments)]
1122    fn make_range(
1123        energy_low: f64,
1124        energy_high: f64,
1125        formalism: ResonanceFormalism,
1126        target_spin: f64,
1127        scattering_radius: f64,
1128        naps: i32,
1129        l: u32,
1130        lgroup_awr: f64,
1131        apl: f64,
1132        resonances: Vec<Resonance>,
1133    ) -> ResonanceRange {
1134        ResonanceRange {
1135            energy_low,
1136            energy_high,
1137            resolved: true,
1138            formalism,
1139            target_spin,
1140            scattering_radius,
1141            naps,
1142            l_groups: vec![LGroup {
1143                l,
1144                awr: lgroup_awr,
1145                apl,
1146                qx: 0.0,
1147                lrx: 0,
1148                resonances,
1149            }],
1150            ap_table: None,
1151            r_external: vec![],
1152            r_matrix_limited: None,
1153        }
1154    }
1155
1156    /// Wrap a `ResonanceRange` in a `ResonanceData` for caller-chosen `(z, a, awr)`.
1157    fn wrap(z: u32, a: u32, awr: f64, range: ResonanceRange) -> ResonanceData {
1158        ResonanceData {
1159            isotope: Isotope::new(z, a).unwrap(),
1160            za: z * 1000 + a,
1161            awr,
1162            smooth: Default::default(),
1163            ranges: vec![range],
1164        }
1165    }
1166
1167    /// One `Resonance` with `gfa = gfb = 0` (the common minimal-fixture case).
1168    fn res(energy: f64, j: f64, gn: f64, gg: f64) -> Resonance {
1169        Resonance {
1170            energy,
1171            j,
1172            gn,
1173            gg,
1174            gfa: 0.0,
1175            gfb: 0.0,
1176        }
1177    }
1178
1179    // --- Public fixtures ---
1180
1181    /// Canonical U-238 6.674 eV Reich-Moore single-resonance.  Byte-identical
1182    /// duplicated copies across pipeline / physics / fitting.
1183    pub fn u238_single_resonance() -> ResonanceData {
1184        u238_with_formalism(ResonanceFormalism::ReichMoore)
1185    }
1186
1187    /// Same as [`u238_single_resonance`] with a caller-chosen formalism.
1188    /// Default RM-style range `1e-5 .. 1e4` eV.
1189    pub fn u238_with_formalism(formalism: ResonanceFormalism) -> ResonanceData {
1190        wrap(
1191            92,
1192            238,
1193            236.006,
1194            make_range(
1195                1e-5,
1196                1e4,
1197                formalism,
1198                0.0,
1199                9.4285,
1200                1,
1201                0,
1202                236.006,
1203                0.0,
1204                vec![res(6.674, 0.5, 1.493e-3, 23.0e-3)],
1205            ),
1206        )
1207    }
1208
1209    /// As [`u238_with_formalism`] with wider range `1e-6 .. 1e5` eV for the
1210    /// velocity-factor regression suite (`slbw_velocity_factor.rs`).
1211    pub fn u238_with_formalism_wide_range(formalism: ResonanceFormalism) -> ResonanceData {
1212        wrap(
1213            92,
1214            238,
1215            236.006,
1216            make_range(
1217                1e-6,
1218                1e5,
1219                formalism,
1220                0.0,
1221                9.4285,
1222                1,
1223                0,
1224                236.006,
1225                0.0,
1226                vec![res(6.674, 0.5, 1.493e-3, 23.0e-3)],
1227            ),
1228        )
1229    }
1230
1231    /// U-238 with three well-separated s-wave resonances (6.674, 20.87,
1232    /// 36.68 eV), Reich-Moore.  Multiple dips at different energies break the
1233    /// (t0, L_scale) degeneracy that a single resonance leaves — a single dip
1234    /// cannot separate a TOF offset from a flight-path scale.  Used by the
1235    /// energy-scale calibration and joint temperature-recovery tests;
1236    /// analogous to (not numerically identical with) the Python
1237    /// `TestFitEnergyScaleRecovery` fixture.
1238    pub fn u238_three_resonances() -> ResonanceData {
1239        wrap(
1240            92,
1241            238,
1242            236.006,
1243            make_range(
1244                1e-6,
1245                1e5,
1246                ResonanceFormalism::ReichMoore,
1247                0.0,
1248                9.4285,
1249                1,
1250                0,
1251                236.006,
1252                0.0,
1253                vec![
1254                    res(6.674, 0.5, 1.493e-3, 23.0e-3),
1255                    res(20.87, 0.5, 10.3e-3, 26.0e-3),
1256                    res(36.68, 0.5, 34.4e-3, 27.0e-3),
1257                ],
1258            ),
1259        )
1260    }
1261
1262    /// Fully-parameterized U-238 ZA single-resonance, Reich-Moore.  For the
1263    /// RM-harness tests that vary (E_r, Γn, Γγ, J, L, AWR, I, AP) per case.
1264    pub fn single_resonance(p: SingleResonanceParams) -> ResonanceData {
1265        wrap(
1266            92,
1267            238,
1268            p.awr,
1269            make_range(
1270                1e-5,
1271                1e4,
1272                ResonanceFormalism::ReichMoore,
1273                p.target_spin,
1274                p.scattering_radius,
1275                1,
1276                p.l,
1277                p.awr,
1278                0.0,
1279                vec![res(p.energy, p.j, p.gamma_n, p.gamma_g)],
1280            ),
1281        )
1282    }
1283
1284    /// Synthetic single-resonance for an arbitrary `(z, a, awr, energy)`.
1285    /// Hard-codes RM, AP=5, I=0, L=0, J=0.5, Γn=1e-3, Γγ=1e-2.  Used by
1286    /// multi-isotope group-fit / calibration tests.
1287    pub fn synthetic_single_resonance(z: u32, a: u32, awr: f64, energy: f64) -> ResonanceData {
1288        wrap(
1289            z,
1290            a,
1291            awr,
1292            make_range(
1293                1e-5,
1294                1e4,
1295                ResonanceFormalism::ReichMoore,
1296                0.0,
1297                5.0,
1298                1,
1299                0,
1300                awr,
1301                0.0,
1302                vec![res(energy, 0.5, 1e-3, 1e-2)],
1303            ),
1304        )
1305    }
1306
1307    /// U-238-ZA single s-wave SLBW over the wider `1e-5 .. 1e6` eV range used
1308    /// by the elastic-oracle regression test (`slbw_elastic_oracle.rs`).
1309    /// I=0 so `g_J = 1` for J=1/2.
1310    pub fn synthetic_swave_slbw(
1311        awr: f64,
1312        e_r_ev: f64,
1313        gn_ev: f64,
1314        gg_ev: f64,
1315        scattering_radius_fm: f64,
1316    ) -> ResonanceData {
1317        wrap(
1318            92,
1319            238,
1320            awr,
1321            make_range(
1322                1e-5,
1323                1e6,
1324                ResonanceFormalism::SLBW,
1325                0.0,
1326                scattering_radius_fm,
1327                1,
1328                0,
1329                awr,
1330                0.0,
1331                vec![res(e_r_ev, 0.5, gn_ev, gg_ev)],
1332            ),
1333        )
1334    }
1335
1336    /// Minimal single-resonance for offline detectability tests.  Auto-derives
1337    /// `awr ≈ a - 0.009` (rough neutron-mass correction); hard-codes RM, AP=6,
1338    /// I=0, L=0, J=0.5.
1339    pub fn synthetic_isotope(z: u32, a: u32, res_energy: f64, gn: f64, gg: f64) -> ResonanceData {
1340        let awr = a as f64 - 0.009;
1341        wrap(
1342            z,
1343            a,
1344            awr,
1345            make_range(
1346                1e-5,
1347                1e4,
1348                ResonanceFormalism::ReichMoore,
1349                0.0,
1350                6.0,
1351                1,
1352                0,
1353                awr,
1354                0.0,
1355                vec![res(res_energy, 0.5, gn, gg)],
1356            ),
1357        )
1358    }
1359
1360    /// Multi-resonance sibling of [`synthetic_isotope`]: N s-wave resonances
1361    /// `(energy_eV, Γn_eV, Γγ_eV)` in **one** L-group of a **single** range —
1362    /// i.e. ONE potential-scattering term.  Deliberately NOT built by stacking
1363    /// N `synthetic_isotope` single-resonance isotopes in a sample: each such
1364    /// isotope carries its own AP hard-sphere background, so a stack N-folds
1365    /// the potential-scattering baseline.  Same structural defaults as
1366    /// [`synthetic_isotope`] (RM, AP=6, I=0, L=0, J=0.5, `awr ≈ a − 0.009`).
1367    pub fn synthetic_isotope_multi(
1368        z: u32,
1369        a: u32,
1370        resonances: &[(f64, f64, f64)],
1371    ) -> ResonanceData {
1372        let awr = a as f64 - 0.009;
1373        wrap(
1374            z,
1375            a,
1376            awr,
1377            make_range(
1378                1e-5,
1379                1e4,
1380                ResonanceFormalism::ReichMoore,
1381                0.0,
1382                6.0,
1383                1,
1384                0,
1385                awr,
1386                0.0,
1387                resonances
1388                    .iter()
1389                    .map(|&(e, gn, gg)| res(e, 0.5, gn, gg))
1390                    .collect(),
1391            ),
1392        )
1393    }
1394
1395    /// Hf-178 MLBW: two s-waves at 7.8 and 16.9 eV in the same J=1/2 group.
1396    /// Range `0 .. 100` eV, AP=9.48, NAPS=0.  MLBW positivity and
1397    /// total-vs-components regression tests in `slbw.rs`.
1398    pub fn hf178_mlbw_two_resonances() -> ResonanceData {
1399        wrap(
1400            72,
1401            178,
1402            177.94,
1403            make_range(
1404                0.0,
1405                100.0,
1406                ResonanceFormalism::MLBW,
1407                0.0,
1408                9.48,
1409                0,
1410                0,
1411                177.94,
1412                0.0,
1413                vec![res(7.8, 0.5, 0.002, 0.060), res(16.9, 0.5, 0.004, 0.055)],
1414            ),
1415        )
1416    }
1417
1418    /// Hf-177 MLBW: two s-waves at 2.386 and 5.89 eV in the same high-J group
1419    /// (J=4.0), target spin I=3.5.  Range `1e-5 .. 1e3` eV, AP=7.0, NAPS=0.
1420    /// MLBW coherent-vs-incoherent dispatcher regression (root cause).
1421    pub fn hf177_mlbw_two_resonances_high_j() -> ResonanceData {
1422        wrap(
1423            72,
1424            177,
1425            175.4232,
1426            make_range(
1427                1e-5,
1428                1e3,
1429                ResonanceFormalism::MLBW,
1430                3.5,
1431                7.0,
1432                0,
1433                0,
1434                175.4232,
1435                0.0,
1436                vec![
1437                    res(2.386, 4.0, 2.0e-3, 60.0e-3),
1438                    res(5.89, 4.0, 3.5e-3, 62.0e-3),
1439                ],
1440            ),
1441        )
1442    }
1443
1444    /// SAMMY ex001 hydrogen-anchor: SLBW single resonance at 10 eV on the
1445    /// synthetic ZA=1010.  Doppler-broadening reference suite.  Widths are
1446    /// in eV (SAMMY par file has them in meV; conversion baked in).
1447    ///
1448    /// The SAMMY input gives this fictitious target a mass of 10 amu, but
1449    /// AWR is mass ÷ NEUTRON mass, so the ratio is 9.9141 and not 10.  The
1450    /// difference is 0.87%, and the free-gas kernel width goes as
1451    /// `1/√AWR`, so the LARGER amu figure makes every broadened curve built
1452    /// from this fixture 0.43% too NARROW (Δ_D 0.32157 eV instead of
1453    /// 0.32296 eV at 10 eV and 300 K).
1454    pub fn ex001_hydrogen_single_resonance() -> ResonanceData {
1455        let awr = 10.0 / nereids_core::constants::NEUTRON_MASS_AMU;
1456        wrap(
1457            1,
1458            10,
1459            awr,
1460            make_range(
1461                0.0,
1462                100.0,
1463                ResonanceFormalism::SLBW,
1464                0.0,
1465                2.908,
1466                1,
1467                0,
1468                awr,
1469                2.908,
1470                vec![res(10.0, 0.5, 0.5e-3, 1.0e-3)],
1471            ),
1472        )
1473    }
1474
1475    /// Minimal SLBW `ResonanceRange` (not a full `ResonanceData`) using
1476    /// U-238-like parameters with a single 6.674 eV s-wave.  For
1477    /// `slbw_cross_sections_for_range` panic tests at the range-level entry.
1478    pub fn minimal_slbw_range() -> ResonanceRange {
1479        make_range(
1480            1e-5,
1481            1e4,
1482            ResonanceFormalism::SLBW,
1483            0.0,
1484            9.4285,
1485            1,
1486            0,
1487            236.006,
1488            0.0,
1489            vec![res(6.674, 0.5, 1.493e-3, 23.0e-3)],
1490        )
1491    }
1492}
1493
1494#[cfg(test)]
1495mod test_support_tests {
1496    use super::ResonanceFormalism;
1497    use super::test_support::*;
1498
1499    #[test]
1500    fn u238_single_resonance_has_canonical_za_and_energy() {
1501        let d = u238_single_resonance();
1502        assert_eq!(d.za, 92238);
1503        assert_eq!(d.ranges[0].l_groups[0].resonances[0].energy, 6.674);
1504    }
1505
1506    #[test]
1507    fn u238_with_formalism_slbw_returns_slbw() {
1508        let d = u238_with_formalism(ResonanceFormalism::SLBW);
1509        assert_eq!(d.ranges[0].formalism, ResonanceFormalism::SLBW);
1510        assert_eq!(d.ranges[0].energy_low, 1e-5);
1511        assert_eq!(d.ranges[0].energy_high, 1e4);
1512    }
1513
1514    #[test]
1515    fn u238_with_formalism_wide_range_uses_wide_bounds() {
1516        let d = u238_with_formalism_wide_range(ResonanceFormalism::MLBW);
1517        assert_eq!(d.ranges[0].energy_low, 1e-6);
1518        assert_eq!(d.ranges[0].energy_high, 1e5);
1519        assert_eq!(d.ranges[0].formalism, ResonanceFormalism::MLBW);
1520    }
1521
1522    #[test]
1523    fn single_resonance_param_struct_builds_rm() {
1524        let d = single_resonance(SingleResonanceParams {
1525            energy: 6.674,
1526            gamma_n: 1.493e-3,
1527            gamma_g: 23.0e-3,
1528            j: 0.5,
1529            l: 0,
1530            awr: 236.006,
1531            target_spin: 0.0,
1532            scattering_radius: 9.4285,
1533        });
1534        assert_eq!(d.ranges[0].formalism, ResonanceFormalism::ReichMoore);
1535        assert_eq!(d.za, 92238);
1536    }
1537
1538    #[test]
1539    fn hf178_mlbw_two_resonances_returns_two() {
1540        let d = hf178_mlbw_two_resonances();
1541        assert_eq!(d.ranges[0].l_groups[0].resonances.len(), 2);
1542        assert_eq!(d.za, 72178);
1543    }
1544
1545    #[test]
1546    fn synthetic_isotope_uses_caller_za() {
1547        let d = synthetic_isotope(74, 184, 10.0, 1e-3, 1e-2);
1548        assert_eq!(d.za, 74184);
1549        assert_eq!(d.ranges[0].l_groups[0].resonances[0].energy, 10.0);
1550    }
1551
1552    #[test]
1553    fn synthetic_isotope_multi_puts_all_resonances_in_one_group() {
1554        // One range, one L-group, one potential-scattering term — NOT N
1555        // stacked single-resonance isotopes (which would N-fold the AP
1556        // background).
1557        let d = synthetic_isotope_multi(
1558            73,
1559            181,
1560            &[
1561                (10.36, 0.003, 0.058),
1562                (24.0, 0.009, 0.060),
1563                (39.1, 0.040, 0.060),
1564            ],
1565        );
1566        assert_eq!(d.za, 73181);
1567        assert_eq!(d.ranges.len(), 1);
1568        assert_eq!(d.ranges[0].l_groups.len(), 1);
1569        let rs = &d.ranges[0].l_groups[0].resonances;
1570        assert_eq!(rs.len(), 3);
1571        assert_eq!(rs[0].energy, 10.36);
1572        assert_eq!(rs[2].gn, 0.040);
1573        // Structural defaults match synthetic_isotope (same awr law, RM, J=0.5).
1574        let single = synthetic_isotope(73, 181, 10.36, 0.003, 0.058);
1575        assert_eq!(d.awr, single.awr);
1576        assert_eq!(d.ranges[0].formalism, single.ranges[0].formalism);
1577        assert_eq!(rs[0].j, single.ranges[0].l_groups[0].resonances[0].j);
1578    }
1579}