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