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