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; used for
170    /// many medium-heavy isotopes (W, Ta, Zr, etc.) in ENDF/B-VIII.0.
171    RMatrixLimited,
172    /// Unresolved Resonance Region (LRU=2). Average cross-sections via
173    /// Hauser-Feshbach formalism. Cross-sections computed in `urr::urr_cross_sections`.
174    Unresolved,
175}
176
177// ─── LRU=2 (Unresolved Resonance Region) Data Structures ─────────────────────
178//
179// The URR uses average level-spacing and width parameters rather than discrete
180// resonances. Cross-sections are computed via the Hauser-Feshbach formula.
181//
182// LRF=1: single energy-independent width set per (L, J); Γ_n derived from
183//        reduced neutron width GNO via Γ_n = 2·P_L·GNO.
184// LRF=2: tabulated energy-dependent widths with an interpolation law per
185//        J-group; supported INT codes enforced by the parser at load time.
186//
187// Reference: ENDF-6 Formats Manual §2.2.2
188
189/// Average widths for one (L, J) combination in the Unresolved Resonance Region.
190///
191/// For LRF=1: `energies` is empty; each width vector has exactly one element.
192/// For LRF=2: all vectors have length NE; `int_code` selects the interpolation
193/// law (INT=1..=5 per ENDF-6 §0.5; validated by the parser, dispatched in
194/// `nereids_physics::urr`).
195///
196/// Reference: ENDF-6 Formats Manual §2.2.2
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct UrrJGroup {
199    /// Total angular momentum J.
200    pub j: f64,
201    /// Neutron χ² degrees of freedom (AMUN).
202    pub amun: f64,
203    /// Fission χ² degrees of freedom (AMUF); 0 for LRF=1 non-fissile.
204    pub amuf: f64,
205    /// Tabulation energies (eV). Empty for LRF=1.
206    pub energies: Vec<f64>,
207    /// Average level spacing D (eV). Single-element for LRF=1.
208    pub d: Vec<f64>,
209    /// Competitive width GX (eV). Single-element 0 for LRF=1.
210    pub gx: Vec<f64>,
211    /// Average neutron width (eV). For LRF=1 this is GNO (reduced width);
212    /// for LRF=2 this is the actual average Γ_n from the table.
213    pub gn: Vec<f64>,
214    /// Average gamma (capture) width GG (eV). Single-element for LRF=1.
215    pub gg: Vec<f64>,
216    /// Average fission width GF (eV). Single-element for LRF=1.
217    pub gf: Vec<f64>,
218    /// Interpolation law for the energy table (LRF=2 only).
219    /// INT=1..=5 per ENDF-6 §0.5 (1: histogram; 2: y linear in E;
220    /// 3: y linear in ln E; 4: ln y linear in E; 5: ln y linear in ln E).
221    /// Ignored for LRF=1 (no table).
222    #[serde(default = "default_int_code")]
223    pub int_code: u32,
224}
225
226fn default_int_code() -> u32 {
227    2
228}
229
230/// Average URR parameters for one L-value.
231///
232/// Reference: ENDF-6 Formats Manual §2.2.2
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct UrrLGroup {
235    /// Orbital angular momentum quantum number.
236    pub l: u32,
237    /// Atomic weight ratio for this L-group.
238    pub awri: f64,
239    /// J-groups within this L-value.
240    pub j_groups: Vec<UrrJGroup>,
241}
242
243/// Complete Unresolved Resonance Region data for one energy range (LRU=2).
244///
245/// Stored in `ResonanceRange::urr` when the range is an URR range.
246///
247/// Reference: ENDF-6 Formats Manual §2.2.2
248#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct UrrData {
250    /// LRF flag: 1 = single-level BWR (energy-independent widths),
251    ///           2 = multi-level BWR (energy-dependent width tables).
252    pub lrf: u32,
253    /// Target spin I.
254    pub spi: f64,
255    /// Scattering radius AP in fm (converted from ENDF 10⁻¹² cm at parse time).
256    pub ap: f64,
257    /// Lower URR energy bound (eV).
258    pub e_low: f64,
259    /// Upper URR energy bound (eV).
260    pub e_high: f64,
261    /// L-groups (one per orbital angular momentum value).
262    pub l_groups: Vec<UrrLGroup>,
263}
264
265/// Top-level container for all resonance data parsed from an ENDF file.
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct ResonanceData {
268    /// The isotope this data belongs to.
269    pub isotope: Isotope,
270    /// ZA identifier (Z*1000 + A).
271    pub za: u32,
272    /// Atomic weight ratio (mass of target / neutron mass).
273    pub awr: f64,
274    /// Energy ranges containing resonance parameters.
275    pub ranges: Vec<ResonanceRange>,
276}
277
278/// A single energy range within the resolved resonance region.
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct ResonanceRange {
281    /// Lower energy bound (eV).
282    pub energy_low: f64,
283    /// Upper energy bound (eV).
284    pub energy_high: f64,
285    /// Resolved (true) or unresolved (false).
286    pub resolved: bool,
287    /// Resonance formalism used in this range.
288    pub formalism: ResonanceFormalism,
289    /// Target spin (I).
290    pub target_spin: f64,
291    /// Scattering radius (fm).
292    ///
293    /// Constant value from the ENDF CONT header AP field.
294    /// When `ap_table` is `Some`, use `scattering_radius_at(energy_ev)` instead
295    /// of reading this field directly — the table provides the energy-dependent
296    /// value, clamping to the nearest endpoint for energies outside the table
297    /// range.  This constant is only used when `ap_table` is `None` (NRO=0).
298    pub scattering_radius: f64,
299    /// NAPS flag: scattering radius calculation control.
300    ///
301    /// NAPS=0: use the channel radius for penetrability/shift calculations.
302    /// NAPS=1: use the scattering radius (AP or AP(E)) for penetrability/shift.
303    /// Reference: ENDF-6 Formats Manual §2.2.1
304    #[serde(default)]
305    pub naps: i32,
306    /// Energy-dependent scattering radius AP(E) (fm), present when NRO=1.
307    ///
308    /// ENDF-6 §2.2.1: when NRO≠0 a TAB1 record immediately follows the range
309    /// CONT header to give AP(E) as a piecewise function.  At each energy the
310    /// table value replaces the constant `scattering_radius` in penetrability,
311    /// shift, and hard-sphere phase calculations.
312    ///
313    /// `None` when the range has NRO=0 (constant AP).
314    ///
315    /// Reference: ENDF-6 Formats Manual §2.2.1; SAMMY `mlb/mmlb1.f90`
316    #[serde(default)]
317    pub ap_table: Option<Tab1>,
318    /// Spin groups for LRF=1/2/3 (L-grouped). Empty for LRF=7 and LRU=2.
319    pub l_groups: Vec<LGroup>,
320    /// R-Matrix Limited data for LRF=7. `None` for LRF=1/2/3 and LRU=2.
321    pub rml: Option<Box<RmlData>>,
322    /// Unresolved Resonance Region data (LRU=2). `None` for all LRU=1 ranges.
323    ///
324    /// When `Some`, cross-sections are computed via the Hauser-Feshbach
325    /// formula in `nereids_physics::urr::urr_cross_sections`.
326    #[serde(default)]
327    pub urr: Option<Box<UrrData>>,
328    /// R-external (background R-matrix) entries per spin group.
329    ///
330    /// Diagonal, real-valued corrections to the R-matrix that approximate
331    /// the effect of distant (unresolved) resonances.  Keyed by (L, J).
332    ///
333    /// Populated from SAMMY's "R-EXTERNAL PARAMETERS FOLLOW" section.
334    /// Empty for ENDF-only data or SAMMY cases without R-external.
335    ///
336    /// SAMMY Ref: Manual Section II.B.1.d, mpar03.f90 Readrx
337    #[serde(default)]
338    pub r_external: Vec<RExternalEntry>,
339}
340
341/// Parameters grouped by orbital angular momentum L.
342///
343/// In ENDF File 2 (LRF=3, Reich-Moore), resonances are grouped by L-value.
344/// Each L-group contains resonances with different J values.
345#[derive(Debug, Clone, Serialize, Deserialize)]
346pub struct LGroup {
347    /// Orbital angular momentum quantum number.
348    pub l: u32,
349    /// Atomic weight ratio for this group.
350    pub awr: f64,
351    /// Channel scattering radius for this L (fm). 0.0 means use the global value.
352    pub apl: f64,
353    /// Q-value for competitive width (eV). Only meaningful for BW formalisms
354    /// (LRF=1/2) where LRX=1; zero otherwise.
355    /// Reference: ENDF-6 Formats Manual §2.2.1.1, L-value CONT record (C2 field).
356    #[serde(default)]
357    pub qx: f64,
358    /// Competitive width flag. LRX=0: no competitive width; LRX=1: competitive
359    /// reaction exists (width = GT - GN - GG - GF). Only used in BW formalisms.
360    /// Reference: ENDF-6 Formats Manual §2.2.1.1, L-value CONT record (L2 field).
361    #[serde(default)]
362    pub lrx: i32,
363    /// Individual resonances in this L-group.
364    pub resonances: Vec<Resonance>,
365}
366
367/// A single resonance entry.
368///
369/// The meaning of the width fields depends on the formalism:
370///
371/// ## Reich-Moore (LRF=3)
372/// - `gn`: Neutron width Γn (eV)
373/// - `gg`: Radiation (gamma) width Γγ (eV)
374/// - `gfa`: First fission width Γf1 (eV), 0.0 if non-fissile
375/// - `gfb`: Second fission width Γf2 (eV), 0.0 if non-fissile
376///
377/// ## SLBW/MLBW (LRF=1/2)
378/// - `gn`: Neutron width Γn (eV)
379/// - `gg`: Radiation width Γγ (eV)
380/// - `gfa`: Fission width Γf (eV)
381/// - `gfb`: Not used (0.0)
382///
383/// Reference: ENDF-6 Formats Manual, Section 2.2.1
384/// Reference: SAMMY manual, Section 2 (Scattering Theory)
385#[derive(Debug, Clone, Serialize, Deserialize)]
386pub struct Resonance {
387    /// Resonance energy (eV).
388    pub energy: f64,
389    /// Total angular momentum J.
390    pub j: f64,
391    /// Neutron width Γn (eV).
392    pub gn: f64,
393    /// Radiation (capture/gamma) width Γγ (eV).
394    pub gg: f64,
395    /// First fission width (eV). Zero for non-fissile isotopes.
396    pub gfa: f64,
397    /// Second fission width (eV). Zero for non-fissile isotopes.
398    pub gfb: f64,
399}
400
401// ─── R-External (Background R-Matrix) ─────────────────────────────────────────
402
403/// R-external (background R-matrix) parameters for a single spin group channel.
404///
405/// Parameterizes smooth R-matrix contribution from distant (unresolved)
406/// resonances.  The background R-matrix is diagonal and real-valued,
407/// parameterized as a logarithmic polynomial in energy.
408///
409/// ## Formula
410/// ```text
411/// R_ext(E) = R_con + R_lin·E + R_quad·E²
412///          + s_lin·(E_up − E_low)
413///          − (s_con + s_lin·E)·ln[(E_up − E) / (E − E_low)]
414/// ```
415///
416/// SAMMY Ref: Manual Section II.B.1.d, mcro2.f90 lines 180-193
417#[derive(Debug, Clone, Default, Serialize, Deserialize)]
418pub struct RExternalEntry {
419    /// Orbital angular momentum L of the spin group.
420    pub l: u32,
421    /// Total angular momentum J (signed, per SAMMY convention).
422    pub j: f64,
423    /// Lower energy bound (eV).
424    pub e_low: f64,
425    /// Upper energy bound (eV).
426    pub e_up: f64,
427    /// Constant term in R-matrix polynomial.
428    pub r_con: f64,
429    /// Linear coefficient (eV⁻¹).
430    pub r_lin: f64,
431    /// Constant logarithmic coefficient.
432    pub s_con: f64,
433    /// Linear logarithmic coefficient (eV⁻¹).
434    pub s_lin: f64,
435    /// Quadratic coefficient (eV⁻²).
436    pub r_quad: f64,
437}
438
439impl RExternalEntry {
440    /// Evaluate R_ext(E) at the given energy.
441    ///
442    /// The polynomial part (`r_con + r_lin·E + r_quad·E²`) applies at all
443    /// energies.  The logarithmic terms are only added when `E` is strictly
444    /// inside `(e_low, e_up)`.
445    ///
446    /// SAMMY Ref: mcro2.f90 Setr_Cro, lines 180-193
447    pub fn evaluate(&self, energy_ev: f64) -> f64 {
448        let e = energy_ev;
449        let mut r = self.r_con + self.r_lin * e + self.r_quad * e * e;
450
451        let e_up_diff = self.e_up - e;
452        let e_low_diff = e - self.e_low;
453        if e_up_diff > 0.0 && e_low_diff > 0.0 {
454            let log_val = (e_up_diff / e_low_diff).ln();
455            r -= (self.s_con + self.s_lin * e) * log_val;
456            r += self.s_lin * (self.e_up - self.e_low);
457        }
458
459        r
460    }
461}
462
463// ─── LRF=7 (R-Matrix Limited) Data Structures ────────────────────────────────
464//
465// LRF=7 organizes resonances by spin group (J,π) rather than L-value.
466// Each spin group has multiple explicit reaction channels. Resonances carry
467// reduced width amplitudes γ per channel, not formal widths Γ.
468//
469// Reference: ENDF-6 Formats Manual §2.2.1.6; SAMMY manual Ch. 3
470// SAMMY source: rml/mrml01.f (reader), rml/mrml11.f (cross-section calc)
471
472/// Particle pair definition for LRF=7 R-Matrix Limited.
473///
474/// Identifies the two particles in a reaction channel (e.g., neutron + W-184,
475/// or gamma + W-185). Used to determine which channels are entrance (neutron)
476/// channels and which are exit (fission, capture) channels.
477///
478/// Reference: ENDF-6 Formats Manual §2.2.1.6, Table 2.2
479#[derive(Debug, Clone, Serialize, Deserialize)]
480pub struct ParticlePair {
481    /// Mass of particle a (neutron = 1.0, in neutron mass units).
482    pub ma: f64,
483    /// Mass of particle b (target nucleus, in neutron mass units).
484    pub mb: f64,
485    /// Charge number Z of particle a, as stored in the ENDF LRF=7 particle-pair list.
486    /// ENDF LRF=7 stores the charge directly: neutron/photon = 0, proton = 1, alpha = 2.
487    /// Reference: SAMMY rml/mrml03.f — `Docoul = Kzb * Kza` (product of charges).
488    pub za: f64,
489    /// Charge number Z of particle b (target or recoil), as stored in ENDF LRF=7.
490    pub zb: f64,
491    /// Spin of particle a (1/2 for neutron).
492    pub ia: f64,
493    /// Spin of particle b (target spin I).
494    pub ib: f64,
495    /// Q-value for this reaction (eV). 0 for elastic.
496    pub q: f64,
497    /// Penetrability flag (ENDF `PNT`, SAMMY `Lpent`).
498    ///
499    /// `PNT=1`: calculate penetrability P_c and shift S_c analytically
500    /// (Blatt-Weisskopf / Coulomb). Used for open particle channels.
501    /// `PNT=0`: no penetrability — the channel contributes only the
502    /// `Ymat(2,Ii) -= 1` term (SAMMY `rml/mrml07.f:118-122`), encoded here as
503    /// `P_c=1, S_c=B_c`. Always the case for the photon/eliminated channel.
504    /// `PNT∉{0,1}` is rejected at parse time (SAMMY `Check_Quantum`,
505    /// `rml/mrml03.f:22`).
506    pub pnt: i32,
507    /// Shift factor flag.
508    ///
509    /// `SHF=1`: calculate shift factor S_c analytically (Blatt-Weisskopf).
510    /// `SHF=0`: do not calculate; treat S_c = B_c so (S_c − B_c) = 0 in level matrix.
511    pub shf: i32,
512    /// ENDF MT number identifying the reaction (2=elastic, 18=fission, 102=capture).
513    pub mt: u32,
514    /// Parity of particle a.
515    pub pa: f64,
516    /// Parity of particle b.
517    pub pb: f64,
518}
519
520/// A single reaction channel within an LRF=7 spin group.
521///
522/// Specifies which particle pair, what orbital angular momentum, and the
523/// radii used to compute penetrabilities and hard-sphere phase shifts.
524///
525/// Reference: ENDF-6 Formats Manual §2.2.1.6, Table 2.3
526#[derive(Debug, Clone, Serialize, Deserialize)]
527pub struct RmlChannel {
528    /// Index into the parent `RmlData::particle_pairs` vector.
529    pub particle_pair_idx: usize,
530    /// Orbital angular momentum quantum number L.
531    pub l: u32,
532    /// Channel spin S = |I ± 1/2|.
533    pub channel_spin: f64,
534    /// Boundary condition B (usually 0.0; shifts the shift factor reference).
535    pub boundary: f64,
536    /// Effective channel radius APE (fm), used to compute the hard-sphere phase φ_l.
537    ///
538    /// Per SAMMY `rml/mrml07.f:129,134` (`Rhof = Zkfe·Ex`, `Zkfe = Z·Rdeff`) the
539    /// EFFECTIVE radius feeds the phase shift (Sinsix), not the penetrability.
540    /// Independently corroborated by PLEIADES `models.py:385` ("Radius for
541    /// potential scattering").
542    pub effective_radius: f64,
543    /// True channel radius APT (fm), used to compute penetrability P_l and shift S_l.
544    ///
545    /// Per SAMMY `rml/mrml07.f:128,136` (`Rho = Zkte·Ex`, `Zkte = Z·Rdtru`) and
546    /// `rml/mrml03.f:244` (Betset width conversion), the TRUE radius feeds the
547    /// penetrability and shift (Pgh). Corroborated by PLEIADES `models.py:386`
548    /// ("Radius for penetrabilities and shifts").
549    pub true_radius: f64,
550}
551
552/// A single resonance in LRF=7 format.
553///
554/// For KRM=2 (standard R-matrix), `widths` contains reduced width amplitudes
555/// γ_c (eV^{1/2}) and `gamma_gamma = 0.0`.
556///
557/// For KRM=3 (Reich-Moore approximation), `widths` contains formal partial widths
558/// Γ_c (eV) and `gamma_gamma` is the capture width Γ_γ (eV) used to form complex
559/// pole energies: Ẽ_n = E_n - i·Γ_γn/2. The reduced amplitudes are derived as
560/// γ_nc = √(Γ_nc / (2·P_c(E_n))).
561///
562/// Reference: ENDF-6 Formats Manual §2.2.1.6; SAMMY manual §3.1
563#[derive(Debug, Clone, Serialize, Deserialize)]
564pub struct RmlResonance {
565    /// Resonance energy (eV).
566    pub energy: f64,
567    /// Width amplitudes per channel (eV^{1/2} for KRM=2; eV for KRM=3).
568    ///
569    /// Sign convention: sign(γ) encodes interference between resonances.
570    /// `widths.len()` equals the number of channels in the parent `SpinGroup`.
571    pub widths: Vec<f64>,
572    /// Capture (gamma) width Γ_γ (eV) for KRM=3 Reich-Moore approximation.
573    ///
574    /// Used to make the R-matrix denominator complex: E_n → E_n - i·Γ_γ/2.
575    /// Zero for KRM=2 (standard R-matrix, no complex energy shift).
576    pub gamma_gamma: f64,
577}
578
579/// A spin group (J, π) in LRF=7 R-Matrix Limited format.
580///
581/// Groups all resonances with the same total angular momentum J and parity π.
582/// Each spin group has its own set of reaction channels.
583///
584/// Reference: ENDF-6 Formats Manual §2.2.1.6; SAMMY rml/mrml01.f
585#[derive(Debug, Clone, Serialize, Deserialize)]
586pub struct SpinGroup {
587    /// Total angular momentum J.
588    pub j: f64,
589    /// Parity: +1.0 (even) or -1.0 (odd).
590    pub parity: f64,
591    /// Reaction channels for this spin group.
592    pub channels: Vec<RmlChannel>,
593    /// Resonances in this spin group.
594    pub resonances: Vec<RmlResonance>,
595    /// True when the ENDF file contained KBK > 0 or KPS > 0 background correction
596    /// records for this spin group.  The records are consumed by the parser but
597    /// the background terms are **not applied** to the cross-section calculation
598    /// (matching SAMMY behaviour: mrml10.f is a matrix utility, not a background
599    /// reader; KPS is explicitly ignored in mrml07.f).  Cross-sections computed
600    /// for spin groups with background corrections are therefore approximate.
601    #[serde(default)]
602    pub has_background_correction: bool,
603}
604
605/// Complete R-Matrix Limited data for one energy range (LRF=7).
606///
607/// Stored in `ResonanceRange::rml` when the formalism is `RMatrixLimited`.
608///
609/// Reference: ENDF-6 Formats Manual §2.2.1.6; SAMMY rml/mrml01.f
610#[derive(Debug, Clone, Serialize, Deserialize)]
611pub struct RmlData {
612    /// Target spin I.
613    pub target_spin: f64,
614    /// Atomic weight ratio (mass of target / neutron mass).
615    pub awr: f64,
616    /// Global scattering radius AP (fm); used as fallback when per-channel APE = 0.
617    pub scattering_radius: f64,
618    /// R-matrix type flag from ENDF CONT header.
619    ///
620    /// KRM=2: Standard multi-channel R-matrix (widths are reduced amplitudes γ).
621    /// KRM=3: Reich-Moore approximation (widths are formal partial widths Γ;
622    ///        capture enters via complex pole energies Ẽ_n = E_n - i·Γ_γ/2).
623    /// Reference: ENDF-6 Formats Manual §2.2.1.6; SAMMY rml/mrml01.f
624    pub krm: u32,
625    /// Particle pair definitions (NPP entries).
626    pub particle_pairs: Vec<ParticlePair>,
627    /// Spin groups (NJS entries), one per (J, π) combination.
628    pub spin_groups: Vec<SpinGroup>,
629}
630
631impl ResonanceData {
632    /// Total number of resonances across all ranges and groups.
633    ///
634    /// For LRF=7 ranges, counts resonances across all spin groups.
635    ///
636    /// A low count for a given evaluation reflects that evaluation's
637    /// resolved-resonance-region (RRR) extent, **not** a dropped energy range.
638    /// The parser reads every NER range and errors on unconsumed MF2/MT151 data,
639    /// so ranges are never silently discarded. For example, Ta-181 in
640    /// ENDF/B-VIII.0 returns 76 (RRR only to 330 eV, plus an unresolved URR that
641    /// contributes 0 discrete resonances), whereas ENDF/B-VIII.1 extended the RRR
642    /// to 2554 eV and returns 565. See `test_parse_ta181_endf8_0_resonance_count`
643    /// in `parser.rs`, which pins the VIII.0 count as a regression guard.
644    pub fn total_resonance_count(&self) -> usize {
645        self.ranges.iter().map(|r| r.resonance_count()).sum()
646    }
647}
648
649impl ResonanceRange {
650    /// Scattering radius at a given neutron energy.
651    ///
652    /// Returns the interpolated value from `ap_table` when NRO=1 (energy-dependent
653    /// radius), or the constant `scattering_radius` when NRO=0.
654    ///
655    /// Use this method in all physics calculations that need the channel radius,
656    /// rather than reading `scattering_radius` directly.
657    ///
658    /// # Arguments
659    /// * `energy_ev` — Lab-frame neutron energy in eV.
660    pub fn scattering_radius_at(&self, energy_ev: f64) -> f64 {
661        if let Some(table) = &self.ap_table {
662            table.evaluate(energy_ev)
663        } else {
664            self.scattering_radius
665        }
666    }
667
668    /// Total resonance count for this range (works for both LRF=1/2/3 and LRF=7).
669    pub fn resonance_count(&self) -> usize {
670        if let Some(rml) = &self.rml {
671            rml.spin_groups.iter().map(|sg| sg.resonances.len()).sum()
672        } else {
673            self.l_groups.iter().map(|lg| lg.resonances.len()).sum()
674        }
675    }
676}
677
678/// Group resonances by their total angular momentum J value (test-only).
679///
680/// Returns a vector of `(J, resonances)` pairs. Two J values are considered
681/// equal if they differ by less than [`nereids_core::constants::QUANTUM_NUMBER_EPS`].
682///
683/// Note: The physics crate uses `group_resonances_by_j` (in `reich_moore.rs`)
684/// for cross-section precomputation, which builds per-resonance invariants
685/// directly during grouping. This function is retained for unit-level tests
686/// of the grouping logic itself.
687#[cfg(test)]
688fn group_by_j(resonances: &[Resonance]) -> Vec<(f64, Vec<&Resonance>)> {
689    let mut groups: Vec<(f64, Vec<&Resonance>)> = Vec::new();
690    for res in resonances {
691        let j = res.j;
692        if let Some(group) = groups
693            .iter_mut()
694            .find(|(gj, _)| (*gj - j).abs() < nereids_core::constants::QUANTUM_NUMBER_EPS)
695        {
696            group.1.push(res);
697        } else {
698            groups.push((j, vec![res]));
699        }
700    }
701    groups
702}
703
704impl std::fmt::Display for ResonanceData {
705    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
706        write!(
707            f,
708            "ResonanceData(ZA={}, AWR={:.4}, ranges={}, total_resonances={})",
709            self.za,
710            self.awr,
711            self.ranges.len(),
712            self.total_resonance_count()
713        )
714    }
715}
716
717#[cfg(test)]
718mod tests {
719    use super::*;
720
721    fn make_linlin_table(points: Vec<(f64, f64)>) -> Tab1 {
722        let n = points.len();
723        Tab1 {
724            boundaries: vec![n],
725            interp_codes: vec![2],
726            points,
727        }
728    }
729
730    /// Linear-linear interpolation in the interior of the table.
731    #[test]
732    fn test_tab1_linlin_interior() {
733        let table = make_linlin_table(vec![(1.0, 10.0), (5.0, 30.0), (10.0, 5.0)]);
734        // midpoint of [1,5]: x=3 → 10 + (3-1)/(5-1) * (30-10) = 10 + 0.5*20 = 20
735        let v = table.evaluate(3.0);
736        assert!((v - 20.0).abs() < 1e-10, "lin-lin midpoint, got {v}");
737        // midpoint of [5,10]: x=7.5 → 30 + (7.5-5)/(10-5) * (5-30) = 30 + 0.5*(-25) = 17.5
738        let v2 = table.evaluate(7.5);
739        assert!(
740            (v2 - 17.5).abs() < 1e-10,
741            "lin-lin second interval, got {v2}"
742        );
743    }
744
745    /// Values outside the table range clamp to the boundary value.
746    #[test]
747    fn test_tab1_clamping() {
748        let table = make_linlin_table(vec![(2.0, 5.0), (8.0, 15.0)]);
749        assert_eq!(table.evaluate(0.0), 5.0, "below low bound");
750        assert_eq!(table.evaluate(100.0), 15.0, "above high bound");
751        assert_eq!(table.evaluate(2.0), 5.0, "at low bound");
752        assert_eq!(table.evaluate(8.0), 15.0, "at high bound");
753    }
754
755    /// Histogram interpolation (INT=1): y stays constant from left endpoint.
756    #[test]
757    fn test_tab1_histogram() {
758        let table = Tab1 {
759            boundaries: vec![3],
760            interp_codes: vec![1],
761            points: vec![(0.0, 10.0), (5.0, 20.0), (10.0, 30.0)],
762        };
763        assert_eq!(
764            table.evaluate(2.5),
765            10.0,
766            "histogram: should return left value"
767        );
768        assert_eq!(table.evaluate(7.5), 20.0, "histogram: second interval");
769    }
770
771    /// Two-region table: lin-lin for low energies, log-x/lin-y (INT=3) for high.
772    #[test]
773    fn test_tab1_multiregion() {
774        // Region 0 (INT=2, lin-lin): points 0..2  (NBT=2)
775        // Region 1 (INT=3, log in x / linear in y): points 2..4  (NBT=4)
776        // Points: (1,1), (3,3), (10,3), (100,30)
777        let table = Tab1 {
778            boundaries: vec![2, 4],
779            interp_codes: vec![2, 3],
780            points: vec![(1.0, 1.0), (3.0, 3.0), (10.0, 3.0), (100.0, 30.0)],
781        };
782        // Interval 0 ([1,3], INT=2 lin-lin): x=2 → 1 + (2-1)/(3-1) * (3-1) = 2
783        assert!(
784            (table.evaluate(2.0) - 2.0).abs() < 1e-10,
785            "region 0 lin-lin"
786        );
787        // Interval 1 ([3,10], INT=3 log-x/lin-y): x=5.
788        // y0==y1==3.0, so any interpolation mode yields 3.0 regardless.
789        // This verifies the region boundary is crossed correctly and that
790        // x=5 routes to interval 1 (not interval 0 or 2).
791        assert!(
792            (table.evaluate(5.0) - 3.0).abs() < 1e-10,
793            "region 1 INT=3 (constant y segment): x=5 should give 3.0"
794        );
795        // Interval 2 ([10,100], INT=3 log-x/lin-y): x=31.62 ≈ sqrt(10*100) = geometric midpoint.
796        // INT=3: t = ln(x/x0) / ln(x1/x0) = ln(31.62/10) / ln(100/10) = ln(3.162)/ln(10) ≈ 0.5
797        // y = y0 + t*(y1 - y0) = 3 + 0.5*(30 - 3) = 16.5
798        let v = table.evaluate(31.62);
799        assert!(
800            (v - 16.5).abs() < 0.1,
801            "region 2 INT=3 at geometric midpoint: expected 16.5, got {v}"
802        );
803    }
804
805    /// scattering_radius_at falls back to constant when ap_table is None.
806    #[test]
807    fn test_scattering_radius_at_constant() {
808        let range = ResonanceRange {
809            energy_low: 1e-5,
810            energy_high: 1e4,
811            resolved: true,
812            formalism: crate::resonance::ResonanceFormalism::ReichMoore,
813            target_spin: 0.0,
814            scattering_radius: 9.4285,
815            naps: 1,
816            ap_table: None,
817            l_groups: vec![],
818            rml: None,
819            urr: None,
820            r_external: vec![],
821        };
822        assert_eq!(range.scattering_radius_at(1.0), 9.4285);
823        assert_eq!(range.scattering_radius_at(1000.0), 9.4285);
824    }
825
826    /// scattering_radius_at interpolates from ap_table when NRO=1.
827    #[test]
828    fn test_scattering_radius_at_energy_dependent() {
829        // AP goes from 8.0 fm at 1 eV to 10.0 fm at 1000 eV (lin-lin).
830        let table = make_linlin_table(vec![(1.0, 8.0), (1000.0, 10.0)]);
831        let range = ResonanceRange {
832            energy_low: 1e-5,
833            energy_high: 1e4,
834            resolved: true,
835            formalism: crate::resonance::ResonanceFormalism::ReichMoore,
836            target_spin: 0.0,
837            scattering_radius: 9.0, // constant fallback (ignored when table is Some)
838            naps: 1,
839            ap_table: Some(table),
840            l_groups: vec![],
841            rml: None,
842            urr: None,
843            r_external: vec![],
844        };
845        // At 1 eV: 8.0 fm
846        assert!((range.scattering_radius_at(1.0) - 8.0).abs() < 1e-10);
847        // At 1000 eV: 10.0 fm
848        assert!((range.scattering_radius_at(1000.0) - 10.0).abs() < 1e-10);
849        // At 500.5 eV (midpoint): 9.0 fm
850        let mid = range.scattering_radius_at(500.5);
851        assert!((mid - 9.0).abs() < 0.01, "midpoint AP ≈ 9.0, got {mid}");
852    }
853
854    /// Log-guard fallback: if an x-coordinate is non-positive in an INT=3
855    /// (log-x, linear-y) interval, evaluate() falls back to lin-lin.
856    #[test]
857    fn test_tab1_log_guard_nonpositive_x() {
858        // INT=3 (log in x, linear in y) with x0=0.0 — 0.0_f64.ln() = -inf without guard.
859        let table = Tab1 {
860            boundaries: vec![2],
861            interp_codes: vec![3], // log in x, linear in y
862            points: vec![(0.0, 8.0), (10.0, 10.0)],
863        };
864        // x=0.0 is at the left boundary; evaluate() clamps to y=8.0 before interpolation.
865        assert!((table.evaluate(0.0) - 8.0).abs() < 1e-10);
866        // x=5.0 is interior; x0=0.0 triggers the log guard → lin-lin fallback.
867        let result = table.evaluate(5.0);
868        assert!(
869            result.is_finite(),
870            "fallback to lin-lin should give finite result, got {result}"
871        );
872    }
873
874    /// Log-guard fallback: if a y-value is non-positive in an INT=4
875    /// (linear-x, log-y) interval, evaluate() falls back to lin-lin.
876    #[test]
877    fn test_tab1_log_guard_nonpositive_y() {
878        // INT=4 (linear in x, log in y) with y0=0.0 — 0.0_f64.ln() = -inf without guard.
879        let table = Tab1 {
880            boundaries: vec![2],
881            interp_codes: vec![4], // linear in x, log in y
882            points: vec![(1.0, 0.0), (10.0, 1.0)],
883        };
884        let result = table.evaluate(5.0);
885        assert!(
886            result.is_finite(),
887            "fallback to lin-lin should give finite result, got {result}"
888        );
889    }
890
891    /// INT=3 (log in x, linear in y): verify correct formula against analytic values.
892    #[test]
893    fn test_tab1_logx_linear_y() {
894        // Points at x=1 (y=0) and x=100 (y=2.0).
895        // At x=10: t = ln(10)/ln(100) = 1/2, y = 0 + 0.5*2 = 1.0
896        let table = Tab1 {
897            boundaries: vec![2],
898            interp_codes: vec![3], // log in x, linear in y
899            points: vec![(1.0, 0.0), (100.0, 2.0)],
900        };
901        let y = table.evaluate(10.0);
902        assert!(
903            (y - 1.0).abs() < 1e-12,
904            "INT=3 at geometric midpoint x=10: expected y=1.0, got {y}"
905        );
906    }
907
908    /// INT=4 (linear in x, log in y): verify correct formula against analytic values.
909    #[test]
910    fn test_tab1_linear_x_logy() {
911        // Points at x=0 (y=1) and x=2 (y=e²).
912        // At x=1 (midpoint): t=0.5, y = exp(0 + 0.5*2) = exp(1) = e
913        let e = std::f64::consts::E;
914        let table = Tab1 {
915            boundaries: vec![2],
916            interp_codes: vec![4], // linear in x, log in y
917            points: vec![(0.0, 1.0), (2.0, e * e)],
918        };
919        let y = table.evaluate(1.0);
920        assert!(
921            (y - e).abs() < 1e-12,
922            "INT=4 at midpoint x=1: expected y=e={e:.6}, got {y:.6}"
923        );
924    }
925
926    #[test]
927    fn test_group_by_j() {
928        // Empty input
929        let groups = group_by_j(&[]);
930        assert!(groups.is_empty());
931
932        // Single resonance
933        let r1 = Resonance {
934            energy: 6.67,
935            j: 0.5,
936            gn: 0.001,
937            gg: 0.023,
938            gfa: 0.0,
939            gfb: 0.0,
940        };
941        let single = [r1.clone()];
942        let groups = group_by_j(&single);
943        assert_eq!(groups.len(), 1);
944        assert_eq!(groups[0].1.len(), 1);
945
946        // Multiple J values
947        let r2 = Resonance {
948            j: 1.5,
949            ..r1.clone()
950        };
951        let r3 = Resonance {
952            j: 0.5,
953            energy: 20.0,
954            ..r1.clone()
955        };
956        let multi = [r1, r2, r3];
957        let groups = group_by_j(&multi);
958        assert_eq!(groups.len(), 2); // J=0.5 and J=1.5
959        // J=0.5 group should have 2 resonances
960        let j05 = groups
961            .iter()
962            .find(|(j, _)| (*j - 0.5).abs() < nereids_core::constants::QUANTUM_NUMBER_EPS)
963            .unwrap();
964        assert_eq!(j05.1.len(), 2);
965    }
966}
967
968/// Synthetic [`ResonanceData`] / [`ResonanceRange`] builders for cross-crate
969/// tests.  Gated on `#[cfg(any(test, feature = "test-support"))]`: visible to
970/// in-crate `#[cfg(test)] mod tests` AND to integration tests in sibling crates
971/// that enable the `test-support` feature in their `[dev-dependencies]`.  Never
972/// compiled into release builds.  Consolidates previously-scattered ad-hoc
973/// builders into one named API, mirroring PR #545's
974/// `nereids_physics::resolution::test_support`.
975#[cfg(any(test, feature = "test-support"))]
976pub mod test_support {
977    use super::{LGroup, Resonance, ResonanceData, ResonanceFormalism, ResonanceRange};
978    use nereids_core::types::Isotope;
979
980    /// Parameters for [`single_resonance`].  No `Default`: every field is
981    /// required because different absorbing sites used different "defaults",
982    /// and forcing callers to be explicit prevents silent drift.
983    pub struct SingleResonanceParams {
984        pub energy: f64,
985        pub gamma_n: f64,
986        pub gamma_g: f64,
987        pub j: f64,
988        pub l: u32,
989        pub awr: f64,
990        pub target_spin: f64,
991        pub scattering_radius: f64,
992    }
993
994    // --- Private structural helpers ---
995    //
996    // The public fixtures below all build a single `ResonanceRange` with one
997    // `LGroup` whose body varies only in well-defined ways.  The two private
998    // helpers below absorb the structural skeleton (resolved/rml/urr/ap_table/
999    // r_external/qx/lrx/gfa/gfb) so each public helper carries only the
1000    // physically-meaningful parameters.
1001
1002    /// One resolved `ResonanceRange` with a single L-group.  All "structural
1003    /// invariants" (resolved, `rml`/`urr`/`ap_table`/`r_external`,
1004    /// `qx`/`lrx`) get the minimal-fixture defaults.
1005    #[allow(clippy::too_many_arguments)]
1006    fn make_range(
1007        energy_low: f64,
1008        energy_high: f64,
1009        formalism: ResonanceFormalism,
1010        target_spin: f64,
1011        scattering_radius: f64,
1012        naps: i32,
1013        l: u32,
1014        lgroup_awr: f64,
1015        apl: f64,
1016        resonances: Vec<Resonance>,
1017    ) -> ResonanceRange {
1018        ResonanceRange {
1019            energy_low,
1020            energy_high,
1021            resolved: true,
1022            formalism,
1023            target_spin,
1024            scattering_radius,
1025            naps,
1026            l_groups: vec![LGroup {
1027                l,
1028                awr: lgroup_awr,
1029                apl,
1030                qx: 0.0,
1031                lrx: 0,
1032                resonances,
1033            }],
1034            rml: None,
1035            urr: None,
1036            ap_table: None,
1037            r_external: vec![],
1038        }
1039    }
1040
1041    /// Wrap a `ResonanceRange` in a `ResonanceData` for caller-chosen `(z, a, awr)`.
1042    fn wrap(z: u32, a: u32, awr: f64, range: ResonanceRange) -> ResonanceData {
1043        ResonanceData {
1044            isotope: Isotope::new(z, a).unwrap(),
1045            za: z * 1000 + a,
1046            awr,
1047            ranges: vec![range],
1048        }
1049    }
1050
1051    /// One `Resonance` with `gfa = gfb = 0` (the common minimal-fixture case).
1052    fn res(energy: f64, j: f64, gn: f64, gg: f64) -> Resonance {
1053        Resonance {
1054            energy,
1055            j,
1056            gn,
1057            gg,
1058            gfa: 0.0,
1059            gfb: 0.0,
1060        }
1061    }
1062
1063    // --- Public fixtures ---
1064
1065    /// Canonical U-238 6.674 eV Reich-Moore single-resonance.  Byte-identical
1066    /// anchor for the most common synthetic case; absorbs four previously-
1067    /// duplicated copies across pipeline / physics / fitting.
1068    pub fn u238_single_resonance() -> ResonanceData {
1069        u238_with_formalism(ResonanceFormalism::ReichMoore)
1070    }
1071
1072    /// Same as [`u238_single_resonance`] with a caller-chosen formalism.
1073    /// Default RM-style range `1e-5 .. 1e4` eV.
1074    pub fn u238_with_formalism(formalism: ResonanceFormalism) -> ResonanceData {
1075        wrap(
1076            92,
1077            238,
1078            236.006,
1079            make_range(
1080                1e-5,
1081                1e4,
1082                formalism,
1083                0.0,
1084                9.4285,
1085                1,
1086                0,
1087                236.006,
1088                0.0,
1089                vec![res(6.674, 0.5, 1.493e-3, 23.0e-3)],
1090            ),
1091        )
1092    }
1093
1094    /// As [`u238_with_formalism`] with wider range `1e-6 .. 1e5` eV for the
1095    /// velocity-factor regression suite (`slbw_velocity_factor.rs`).
1096    pub fn u238_with_formalism_wide_range(formalism: ResonanceFormalism) -> ResonanceData {
1097        wrap(
1098            92,
1099            238,
1100            236.006,
1101            make_range(
1102                1e-6,
1103                1e5,
1104                formalism,
1105                0.0,
1106                9.4285,
1107                1,
1108                0,
1109                236.006,
1110                0.0,
1111                vec![res(6.674, 0.5, 1.493e-3, 23.0e-3)],
1112            ),
1113        )
1114    }
1115
1116    /// U-238 with three well-separated s-wave resonances (6.674, 20.87,
1117    /// 36.68 eV), Reich-Moore.  Multiple dips at different energies break the
1118    /// (t0, L_scale) degeneracy that a single resonance leaves — a single dip
1119    /// cannot separate a TOF offset from a flight-path scale.  Used by the
1120    /// energy-scale calibration and joint temperature-recovery tests (#634);
1121    /// analogous to (not numerically identical with) the Python
1122    /// `TestFitEnergyScaleRecovery` fixture.
1123    pub fn u238_three_resonances() -> ResonanceData {
1124        wrap(
1125            92,
1126            238,
1127            236.006,
1128            make_range(
1129                1e-6,
1130                1e5,
1131                ResonanceFormalism::ReichMoore,
1132                0.0,
1133                9.4285,
1134                1,
1135                0,
1136                236.006,
1137                0.0,
1138                vec![
1139                    res(6.674, 0.5, 1.493e-3, 23.0e-3),
1140                    res(20.87, 0.5, 10.3e-3, 26.0e-3),
1141                    res(36.68, 0.5, 34.4e-3, 27.0e-3),
1142                ],
1143            ),
1144        )
1145    }
1146
1147    /// Fully-parameterized U-238 ZA single-resonance, Reich-Moore.  For the
1148    /// RM-harness tests that vary (E_r, Γn, Γγ, J, L, AWR, I, AP) per case.
1149    pub fn single_resonance(p: SingleResonanceParams) -> ResonanceData {
1150        wrap(
1151            92,
1152            238,
1153            p.awr,
1154            make_range(
1155                1e-5,
1156                1e4,
1157                ResonanceFormalism::ReichMoore,
1158                p.target_spin,
1159                p.scattering_radius,
1160                1,
1161                p.l,
1162                p.awr,
1163                0.0,
1164                vec![res(p.energy, p.j, p.gamma_n, p.gamma_g)],
1165            ),
1166        )
1167    }
1168
1169    /// Synthetic single-resonance for an arbitrary `(z, a, awr, energy)`.
1170    /// Hard-codes RM, AP=5, I=0, L=0, J=0.5, Γn=1e-3, Γγ=1e-2.  Used by
1171    /// multi-isotope group-fit / calibration tests.
1172    pub fn synthetic_single_resonance(z: u32, a: u32, awr: f64, energy: f64) -> ResonanceData {
1173        wrap(
1174            z,
1175            a,
1176            awr,
1177            make_range(
1178                1e-5,
1179                1e4,
1180                ResonanceFormalism::ReichMoore,
1181                0.0,
1182                5.0,
1183                1,
1184                0,
1185                awr,
1186                0.0,
1187                vec![res(energy, 0.5, 1e-3, 1e-2)],
1188            ),
1189        )
1190    }
1191
1192    /// U-238-ZA single s-wave SLBW over the wider `1e-5 .. 1e6` eV range used
1193    /// by the elastic-oracle regression test (`slbw_elastic_oracle.rs`).
1194    /// I=0 so `g_J = 1` for J=1/2.
1195    pub fn synthetic_swave_slbw(
1196        awr: f64,
1197        e_r_ev: f64,
1198        gn_ev: f64,
1199        gg_ev: f64,
1200        scattering_radius_fm: f64,
1201    ) -> ResonanceData {
1202        wrap(
1203            92,
1204            238,
1205            awr,
1206            make_range(
1207                1e-5,
1208                1e6,
1209                ResonanceFormalism::SLBW,
1210                0.0,
1211                scattering_radius_fm,
1212                1,
1213                0,
1214                awr,
1215                0.0,
1216                vec![res(e_r_ev, 0.5, gn_ev, gg_ev)],
1217            ),
1218        )
1219    }
1220
1221    /// Minimal single-resonance for offline detectability tests.  Auto-derives
1222    /// `awr ≈ a - 0.009` (rough neutron-mass correction); hard-codes RM, AP=6,
1223    /// I=0, L=0, J=0.5.
1224    pub fn synthetic_isotope(z: u32, a: u32, res_energy: f64, gn: f64, gg: f64) -> ResonanceData {
1225        let awr = a as f64 - 0.009;
1226        wrap(
1227            z,
1228            a,
1229            awr,
1230            make_range(
1231                1e-5,
1232                1e4,
1233                ResonanceFormalism::ReichMoore,
1234                0.0,
1235                6.0,
1236                1,
1237                0,
1238                awr,
1239                0.0,
1240                vec![res(res_energy, 0.5, gn, gg)],
1241            ),
1242        )
1243    }
1244
1245    /// Multi-resonance sibling of [`synthetic_isotope`]: N s-wave resonances
1246    /// `(energy_eV, Γn_eV, Γγ_eV)` in **one** L-group of a **single** range —
1247    /// i.e. ONE potential-scattering term.  Deliberately NOT built by stacking
1248    /// N `synthetic_isotope` single-resonance isotopes in a sample: each such
1249    /// isotope carries its own AP hard-sphere background, so a stack N-folds
1250    /// the potential-scattering baseline.  Same structural defaults as
1251    /// [`synthetic_isotope`] (RM, AP=6, I=0, L=0, J=0.5, `awr ≈ a − 0.009`).
1252    pub fn synthetic_isotope_multi(
1253        z: u32,
1254        a: u32,
1255        resonances: &[(f64, f64, f64)],
1256    ) -> ResonanceData {
1257        let awr = a as f64 - 0.009;
1258        wrap(
1259            z,
1260            a,
1261            awr,
1262            make_range(
1263                1e-5,
1264                1e4,
1265                ResonanceFormalism::ReichMoore,
1266                0.0,
1267                6.0,
1268                1,
1269                0,
1270                awr,
1271                0.0,
1272                resonances
1273                    .iter()
1274                    .map(|&(e, gn, gg)| res(e, 0.5, gn, gg))
1275                    .collect(),
1276            ),
1277        )
1278    }
1279
1280    /// Hf-178 MLBW: two s-waves at 7.8 and 16.9 eV in the same J=1/2 group.
1281    /// Range `0 .. 100` eV, AP=9.48, NAPS=0.  MLBW positivity and
1282    /// total-vs-components regression tests in `slbw.rs`.
1283    pub fn hf178_mlbw_two_resonances() -> ResonanceData {
1284        wrap(
1285            72,
1286            178,
1287            177.94,
1288            make_range(
1289                0.0,
1290                100.0,
1291                ResonanceFormalism::MLBW,
1292                0.0,
1293                9.48,
1294                0,
1295                0,
1296                177.94,
1297                0.0,
1298                vec![res(7.8, 0.5, 0.002, 0.060), res(16.9, 0.5, 0.004, 0.055)],
1299            ),
1300        )
1301    }
1302
1303    /// Hf-177 MLBW: two s-waves at 2.386 and 5.89 eV in the same high-J group
1304    /// (J=4.0), target spin I=3.5.  Range `1e-5 .. 1e3` eV, AP=7.0, NAPS=0.
1305    /// MLBW coherent-vs-incoherent dispatcher regression (PR #465 root cause).
1306    pub fn hf177_mlbw_two_resonances_high_j() -> ResonanceData {
1307        wrap(
1308            72,
1309            177,
1310            175.4232,
1311            make_range(
1312                1e-5,
1313                1e3,
1314                ResonanceFormalism::MLBW,
1315                3.5,
1316                7.0,
1317                0,
1318                0,
1319                175.4232,
1320                0.0,
1321                vec![
1322                    res(2.386, 4.0, 2.0e-3, 60.0e-3),
1323                    res(5.89, 4.0, 3.5e-3, 62.0e-3),
1324                ],
1325            ),
1326        )
1327    }
1328
1329    /// SAMMY ex001 hydrogen-anchor: SLBW single resonance at 10 eV on the
1330    /// synthetic ZA=1010 (AWR=10).  Doppler-broadening reference suite.
1331    /// Widths are in eV (SAMMY par file has them in meV; conversion baked in).
1332    pub fn ex001_hydrogen_single_resonance() -> ResonanceData {
1333        wrap(
1334            1,
1335            10,
1336            10.0,
1337            make_range(
1338                0.0,
1339                100.0,
1340                ResonanceFormalism::SLBW,
1341                0.0,
1342                2.908,
1343                1,
1344                0,
1345                10.0,
1346                2.908,
1347                vec![res(10.0, 0.5, 0.5e-3, 1.0e-3)],
1348            ),
1349        )
1350    }
1351
1352    /// Minimal SLBW `ResonanceRange` (not a full `ResonanceData`) using
1353    /// U-238-like parameters with a single 6.674 eV s-wave.  For
1354    /// `slbw_cross_sections_for_range` panic tests at the range-level entry.
1355    pub fn minimal_slbw_range() -> ResonanceRange {
1356        make_range(
1357            1e-5,
1358            1e4,
1359            ResonanceFormalism::SLBW,
1360            0.0,
1361            9.4285,
1362            1,
1363            0,
1364            236.006,
1365            0.0,
1366            vec![res(6.674, 0.5, 1.493e-3, 23.0e-3)],
1367        )
1368    }
1369}
1370
1371#[cfg(test)]
1372mod test_support_tests {
1373    use super::ResonanceFormalism;
1374    use super::test_support::*;
1375
1376    #[test]
1377    fn u238_single_resonance_has_canonical_za_and_energy() {
1378        let d = u238_single_resonance();
1379        assert_eq!(d.za, 92238);
1380        assert_eq!(d.ranges[0].l_groups[0].resonances[0].energy, 6.674);
1381    }
1382
1383    #[test]
1384    fn u238_with_formalism_slbw_returns_slbw() {
1385        let d = u238_with_formalism(ResonanceFormalism::SLBW);
1386        assert_eq!(d.ranges[0].formalism, ResonanceFormalism::SLBW);
1387        assert_eq!(d.ranges[0].energy_low, 1e-5);
1388        assert_eq!(d.ranges[0].energy_high, 1e4);
1389    }
1390
1391    #[test]
1392    fn u238_with_formalism_wide_range_uses_wide_bounds() {
1393        let d = u238_with_formalism_wide_range(ResonanceFormalism::MLBW);
1394        assert_eq!(d.ranges[0].energy_low, 1e-6);
1395        assert_eq!(d.ranges[0].energy_high, 1e5);
1396        assert_eq!(d.ranges[0].formalism, ResonanceFormalism::MLBW);
1397    }
1398
1399    #[test]
1400    fn single_resonance_param_struct_builds_rm() {
1401        let d = single_resonance(SingleResonanceParams {
1402            energy: 6.674,
1403            gamma_n: 1.493e-3,
1404            gamma_g: 23.0e-3,
1405            j: 0.5,
1406            l: 0,
1407            awr: 236.006,
1408            target_spin: 0.0,
1409            scattering_radius: 9.4285,
1410        });
1411        assert_eq!(d.ranges[0].formalism, ResonanceFormalism::ReichMoore);
1412        assert_eq!(d.za, 92238);
1413    }
1414
1415    #[test]
1416    fn hf178_mlbw_two_resonances_returns_two() {
1417        let d = hf178_mlbw_two_resonances();
1418        assert_eq!(d.ranges[0].l_groups[0].resonances.len(), 2);
1419        assert_eq!(d.za, 72178);
1420    }
1421
1422    #[test]
1423    fn synthetic_isotope_uses_caller_za() {
1424        let d = synthetic_isotope(74, 184, 10.0, 1e-3, 1e-2);
1425        assert_eq!(d.za, 74184);
1426        assert_eq!(d.ranges[0].l_groups[0].resonances[0].energy, 10.0);
1427    }
1428
1429    #[test]
1430    fn synthetic_isotope_multi_puts_all_resonances_in_one_group() {
1431        // One range, one L-group, one potential-scattering term — NOT N
1432        // stacked single-resonance isotopes (which would N-fold the AP
1433        // background).
1434        let d = synthetic_isotope_multi(
1435            73,
1436            181,
1437            &[
1438                (10.36, 0.003, 0.058),
1439                (24.0, 0.009, 0.060),
1440                (39.1, 0.040, 0.060),
1441            ],
1442        );
1443        assert_eq!(d.za, 73181);
1444        assert_eq!(d.ranges.len(), 1);
1445        assert_eq!(d.ranges[0].l_groups.len(), 1);
1446        let rs = &d.ranges[0].l_groups[0].resonances;
1447        assert_eq!(rs.len(), 3);
1448        assert_eq!(rs[0].energy, 10.36);
1449        assert_eq!(rs[2].gn, 0.040);
1450        // Structural defaults match synthetic_isotope (same awr law, RM, J=0.5).
1451        let single = synthetic_isotope(73, 181, 10.36, 0.003, 0.058);
1452        assert_eq!(d.awr, single.awr);
1453        assert_eq!(d.ranges[0].formalism, single.ranges[0].formalism);
1454        assert_eq!(rs[0].j, single.ranges[0].l_groups[0].resonances[0].j);
1455    }
1456}