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