Skip to main content

nereids_endf/
parser.rs

1//! ENDF-6 File 2 resonance parameter, File 3 cross section and File 32
2//! covariance parser.
3//!
4//! Parses the fixed-width 80-character ENDF-6 format to extract resolved
5//! resonance region (RRR) parameters.
6//!
7//! ## ENDF-6 Line Format
8//! Each line is exactly 80 characters:
9//! - Cols 1-11:  Field 1 (floating point or integer)
10//! - Cols 12-22: Field 2
11//! - Cols 23-33: Field 3
12//! - Cols 34-44: Field 4
13//! - Cols 45-55: Field 5
14//! - Cols 56-66: Field 6
15//! - Cols 67-70: MAT number
16//! - Cols 71-72: MF (file number)
17//! - Cols 73-75: MT (section number)
18//! - Cols 76-80: Line sequence
19//!
20//! ## SAMMY Reference
21//! - SAMMY manual Section 9 (ENDF-6 format)
22//! - SAMMY source: `sammy/src/endf/` module
23
24use crate::resonance::*;
25use nereids_core::elements::isotope_from_za;
26
27/// ENDF radius unit conversion factor.
28///
29/// ENDF-6 Formats Manual §2.1: "AP, APE, APT are in units of 10⁻¹² cm."
30/// Physics convention: 1 fm = 10⁻¹³ cm, so 10⁻¹² cm = 10 fm.
31/// All ENDF radii (AP, APL, APE, APT, AP(E) tables) are multiplied by this
32/// factor at parse time so that downstream physics uses true femtometers.
33///
34/// SAMMY applies the identical ×10 conversion when reading ENDF:
35///   `FillSammyRmatrixFromRMat.cpp` line 422: `newChannel->getApe() * 10.0`
36const ENDF_RADIUS_TO_FM: f64 = 10.0;
37
38/// Parse ENDF-6 File 2 resonance parameters, and File 3's cross sections
39/// when the text holds them, from raw ENDF text.
40///
41/// Extracts all MF=2, MT=151 lines and parses the resolved resonance region;
42/// File 3's sections MT=1, 2, 102 and 18 become [`ResonanceData::smooth`].
43/// An evaluation File 1 marks LRP=2 is refused.
44///
45/// # Arguments
46/// * `endf_text` — Full ENDF file contents as a string.
47///
48/// # Returns
49/// `ResonanceData` containing all parsed resonance parameters.
50pub fn parse_endf_file2(endf_text: &str) -> Result<ResonanceData, EndfParseError> {
51    let data = parse_file2_records(endf_text)?;
52
53    // Fail loudly when the evaluation carries NO evaluable content. A file
54    // whose every range is a parse-and-skip placeholder (LRU=2, or an
55    // LRU=0 scattering-radius-only stanza) would otherwise "load" with zero
56    // resonances and add no resonance cross section anywhere, with no signal
57    // to the caller. Files with at least one evaluable range still load:
58    // parse-and-skip exists for real mixed tapes
59    // (e.g. Ta-181, U-238, Pu-240) whose resolved ranges remain fully usable.
60    if !data.has_evaluable_range() {
61        let detail = if data.ranges.is_empty() {
62            "the file carries no resonance-parameter ranges at all".to_string()
63        } else {
64            let skipped: Vec<String> = data.ranges.iter().map(|r| r.skip_description()).collect();
65            format!(
66                "every range in this file is a parse-and-skip placeholder: {}",
67                skipped.join("; ")
68            )
69        };
70        return Err(EndfParseError::UnsupportedFormat(format!(
71            "No evaluable resonance ranges: NEREIDS evaluates resolved LRF=3 \
72             (Reich-Moore) ranges, LRF=1/2 (SLBW/MLBW) ranges without a competitive \
73             width (LRX=0), and LRF=7 (R-Matrix Limited) ranges in the Reich-Moore \
74             form (KRM=3); {detail}. No resonance would contribute a cross section \
75             anywhere on the energy grid."
76        )));
77    }
78
79    Ok(data)
80}
81
82/// Parse File 2 (and File 3) records without the evaluable-range check.
83fn parse_file2_records(endf_text: &str) -> Result<ResonanceData, EndfParseError> {
84    let lines = section_151(endf_text, "2");
85
86    if lines.is_empty() {
87        return Err(EndfParseError::MissingSection(
88            "No MF=2, MT=151 data found".to_string(),
89        ));
90    }
91
92    let mut pos = 0;
93
94    // HEAD record: ZA, AWR, 0, 0, NIS, 0
95    let head = parse_cont(&lines, &mut pos)?;
96    let za = head.c1 as u32;
97    let awr = head.c2;
98    let nis = checked_count(head.n1, "NIS")?; // number of isotopes (usually 1)
99
100    // ENDF-6 §2.1 requires NIS >= 1 for a valid resonance evaluation. NIS=0
101    // would leave the parser with no isotope subsection to read and fall
102    // through to a confusing "unconsumed data lines" downstream failure;
103    // reject up-front with a clear message.
104    if nis == 0 {
105        return Err(EndfParseError::UnsupportedFormat(
106            "MF=2 NIS=0: no isotopes declared. ENDF-6 §2.1 requires NIS >= 1 \
107             for a valid resonance evaluation."
108                .into(),
109        ));
110    }
111    // ENDF-6 §2.1: a material with NIS>1 contains multiple isotope subsections,
112    // each carrying its own ZAI, ABN, LFW, and NER ranges (e.g. natural-element
113    // evaluations such as nat-C with ZAI={6012,6013}). The reference reader
114    // OpenScale (File2.cpp:71-87) stores these in a Vec<ResonanceIsotope>
115    // tagged with per-isotope ABN, and downstream physics combines the per-
116    // isotope cross sections with that ABN weighting. NEREIDS's `ResonanceData`
117    // has no per-isotope discriminator and no abundance field; silently
118    // flattening multi-isotope subsections into one flat range list would
119    // discard ZAI/ABN and produce abundance-blind cross sections. Reject
120    // the case explicitly until a proper multi-isotope container is wired
121    // through. Single-isotope (NIS=1) ENDF evaluations remain fully supported.
122    if nis > 1 {
123        return Err(EndfParseError::UnsupportedFormat(format!(
124            "MF=2 NIS={nis} > 1 multi-isotope materials are not supported. \
125             Each NEREIDS ResonanceData represents a single isotope. \
126             For multi-isotope ENDF evaluations, split the material into per-isotope \
127             files or use sammy_to_resonance_data_multi from nereids_endf::sammy."
128        )));
129    }
130
131    let isotope = isotope_from_za(za)?;
132    let mut all_ranges = Vec::new();
133
134    for _ in 0..nis {
135        // Isotope CONT: ZAI, ABN, 0, LFW, NER, 0
136        let iso_cont = parse_cont(&lines, &mut pos)?;
137        let _zai = iso_cont.c1 as u32;
138        let _abn = iso_cont.c2; // abundance
139        let lfw = iso_cont.l2; // fission width flag (LFW=1 → energy-dependent fission widths in URR)
140        let ner = checked_count(iso_cont.n1, "NER")?; // number of energy ranges
141
142        for _ in 0..ner {
143            // Range CONT: EL, EH, LRU, LRF, NRO, NAPS
144            let range_cont = parse_cont(&lines, &mut pos)?;
145            let energy_low = range_cont.c1;
146            let energy_high = range_cont.c2;
147            let lru = range_cont.l1; // 1=resolved, 2=unresolved
148            let lrf = range_cont.l2; // resonance formalism
149
150            if lru == 2 {
151                // Unresolved resonance region (LRU=2): parsed and SKIPPED.
152                //
153                // NEREIDS does not compute URR average cross sections — the
154                // width-fluctuation correction, a systematically wrong average).
155                // We still consume the URR body to keep the line stream aligned
156                // so resolved ranges in the same evaluation remain accessible,
157                // and tag the range Unresolved (non-evaluable → Skip in the
158                // physics layer). Structural guards inside the skip helpers
159                // detect malformed records that would otherwise misalign the
160                // cursor. Reference: ENDF-6 §2.2.2.
161                let nro_urr = range_cont.n1; // energy-dependent scattering radius flag
162                let naps_urr = range_cont.n2; // scattering radius calculation flag
163                // A TAB1 AP(E) record precedes the URR body when NRO != 0.
164                // Store it (converted to fm) on the placeholder: the placeholder
165                // preserves everything the header carries even though the range
166                // is never evaluated.
167                let ap_table_urr = if nro_urr != 0 {
168                    let mut tab = parse_tab1(&lines, &mut pos)?;
169                    for pt in &mut tab.points {
170                        pt.1 *= ENDF_RADIUS_TO_FM;
171                    }
172                    Some(tab)
173                } else {
174                    None
175                };
176
177                // LRF must be 1 or 2 for the URR (ENDF-6 §2.2.2). Any other
178                // value is a malformed URR record; reject it as an unsupported
179                // format — consistent with the sibling malformed-record guards
180                // (INT range, N1 relations) — rather than heuristically
181                // consuming a guessed body and silently dropping the span.
182                if lrf != 1 && lrf != 2 {
183                    return Err(EndfParseError::UnsupportedFormat(format!(
184                        "LRU=2 (URR) with LRF={lrf}: ENDF-6 §2.2.2 restricts \
185                         the unresolved region to LRF=1 or LRF=2"
186                    )));
187                }
188
189                // LFW=1/LRF=1 uses the "Case B" shared-energy-grid layout
190                // (ENDF-6 §2.2.2.1); LFW=1/LRF=2 is byte-identical to LFW=0/LRF=2.
191                let (spi_urr, ap_urr_fm) = if lfw != 0 && lrf == 1 {
192                    skip_urr_lfw1_lrf1(&lines, &mut pos)?
193                } else {
194                    skip_urr_range(&lines, &mut pos, lrf)?
195                };
196
197                all_ranges.push(ResonanceRange {
198                    energy_low,
199                    energy_high,
200                    resolved: false,
201                    formalism: ResonanceFormalism::Unresolved,
202                    target_spin: spi_urr,
203                    scattering_radius: ap_urr_fm,
204                    naps: naps_urr,
205                    ap_table: ap_table_urr,
206                    l_groups: Vec::new(),
207                    r_external: vec![],
208                    r_matrix_limited: None,
209                });
210                continue;
211            }
212
213            if lru == 0 {
214                // LRU=0: scattering-radius-only range (no resonance parameters).
215                // ENDF-6 §2.1: after the range CONT (and optional TAB1 if
216                // NRO!=0), a single CONT record follows: [SPI, AP, 0, 0,
217                // NLS=0, 0]. We consume the NRO TAB1 if present, then the CONT,
218                // and store a non-evaluable placeholder (rather than dropping
219                // the range) so the LRU=0 span is named by skip_description and
220                // the no-evaluable-content error — a file whose only range is
221                // LRU=0 is still rejected (NEREIDS cannot evaluate a
222                // resonance-parameter-free stanza, and loading it would yield
223                // silent zero cross-sections), but with an accurate message.
224                let nro_lru0 = range_cont.n1;
225                let naps_lru0 = range_cont.n2;
226                let ap_table_lru0 = if nro_lru0 != 0 {
227                    // TAB1 AP(E) precedes the SPI/AP CONT; consume and store it.
228                    let mut tab = parse_tab1(&lines, &mut pos)?;
229                    for pt in &mut tab.points {
230                        pt.1 *= ENDF_RADIUS_TO_FM;
231                    }
232                    Some(tab)
233                } else {
234                    None
235                };
236                // CONT: SPI, AP, 0, 0, NLS=0, 0
237                // Validate NLS=0: a non-zero NLS in an LRU=0 range is
238                // malformed and would cause the parser to look for L-groups that
239                // don't exist, misaligning the cursor for subsequent ranges.
240                let spi_cont = parse_cont(&lines, &mut pos)?;
241                // ENDF-6 §2.1: the SPI/AP CONT is [SPI, AP, 0, 0, NLS=0, 0].
242                // Validate that L1 and L2 are both zero — non-zero values
243                // indicate a malformed or mis-identified record.
244                if spi_cont.l1 != 0 {
245                    return Err(EndfParseError::UnsupportedFormat(format!(
246                        "LRU=0 range: L1={} in SPI/AP CONT record must be 0",
247                        spi_cont.l1
248                    )));
249                }
250                if spi_cont.l2 != 0 {
251                    return Err(EndfParseError::UnsupportedFormat(format!(
252                        "LRU=0 range: L2={} in SPI/AP CONT record must be 0",
253                        spi_cont.l2
254                    )));
255                }
256                if spi_cont.n1 != 0 {
257                    return Err(EndfParseError::UnsupportedFormat(format!(
258                        "LRU=0 range: NLS={} in SPI/AP CONT record must be 0 \
259                         (scattering-radius-only ranges have no L-groups)",
260                        spi_cont.n1
261                    )));
262                }
263                if spi_cont.n2 != 0 {
264                    return Err(EndfParseError::UnsupportedFormat(format!(
265                        "LRU=0 range: N2={} in SPI/AP CONT record must be 0",
266                        spi_cont.n2
267                    )));
268                }
269                all_ranges.push(ResonanceRange {
270                    energy_low,
271                    energy_high,
272                    resolved: false,
273                    formalism: ResonanceFormalism::ScatteringRadiusOnly,
274                    target_spin: spi_cont.c1,
275                    scattering_radius: spi_cont.c2 * ENDF_RADIUS_TO_FM,
276                    naps: naps_lru0,
277                    ap_table: ap_table_lru0,
278                    l_groups: Vec::new(),
279                    r_external: vec![],
280                    r_matrix_limited: None,
281                });
282                continue;
283            }
284
285            if lru != 1 {
286                return Err(EndfParseError::UnsupportedFormat(format!(
287                    "LRU={} not supported (expected 0=scattering-radius-only, 1=resolved, or 2=unresolved)",
288                    lru
289                )));
290            }
291
292            let nro = range_cont.n1; // energy-dependent scattering radius flag
293            let naps = range_cont.n2; // scattering radius calculation flag
294
295            // If NRO != 0, a TAB1 record immediately follows giving AP(E).
296            // Parse and store it; scattering_radius_at(E) will interpolate it
297            // at each energy point.  Reference: ENDF-6 §2.2.1; SAMMY mlb/mmlb1.f90.
298            let ap_table = if nro != 0 {
299                let mut tab = parse_tab1(&lines, &mut pos)?;
300                // AP(E) y-values are in 10⁻¹² cm; convert to fm.
301                for pt in &mut tab.points {
302                    pt.1 *= ENDF_RADIUS_TO_FM;
303                }
304                Some(tab)
305            } else {
306                None
307            };
308
309            // ENDF-6 Formats Manual: LRF values for resolved resonance region
310            // LRF=1: Single-Level Breit-Wigner (SLBW)
311            // LRF=2: Multi-Level Breit-Wigner (MLBW)
312            // LRF=3: Reich-Moore
313            // LRF=4: Adler-Adler (deprecated, not supported)
314            // LRF=7: R-Matrix Limited (general)
315            let formalism = match lrf {
316                1 => ResonanceFormalism::SLBW,
317                2 => ResonanceFormalism::MLBW,
318                3 => ResonanceFormalism::ReichMoore,
319                7 => ResonanceFormalism::RMatrixLimited,
320                _ => {
321                    return Err(EndfParseError::UnsupportedFormat(format!(
322                        "LRF={} not yet supported",
323                        lrf
324                    )));
325                }
326            };
327
328            let mut ctx = RangeParseContext {
329                lines: &lines,
330                pos: &mut pos,
331                energy_low,
332                energy_high,
333                naps,
334                ap_table,
335            };
336            let range = match formalism {
337                ResonanceFormalism::MLBW | ResonanceFormalism::SLBW => {
338                    parse_bw_range(&mut ctx, formalism)?
339                }
340                ResonanceFormalism::ReichMoore => parse_reich_moore_range(&mut ctx)?,
341                ResonanceFormalism::RMatrixLimited => parse_rmatrix_limited_range(&mut ctx)?,
342                ResonanceFormalism::Unresolved => {
343                    // Unreachable: Unresolved is only assigned in the LRU=2 branch above.
344                    unreachable!("Unresolved formalism should not appear in LRU=1 dispatch");
345                }
346                ResonanceFormalism::ScatteringRadiusOnly => {
347                    // Unreachable: ScatteringRadiusOnly is only assigned in the
348                    // LRU=0 branch above.
349                    unreachable!(
350                        "ScatteringRadiusOnly formalism should not appear in LRU=1 dispatch"
351                    );
352                }
353            };
354            all_ranges.push(range);
355        }
356    }
357
358    if pos < lines.len() {
359        return Err(EndfParseError::UnsupportedFormat(
360            "Multiple materials detected in MF=2/MT=151: unconsumed data lines \
361             remain after parsing the first material. Multi-MAT files are not \
362             supported; split the file into single-material ENDF files."
363                .to_string(),
364        ));
365    }
366
367    let mat = lines[0].get(66..70).unwrap_or("").trim();
368    if let Some(lrp) = section(endf_text, mat, "1", "451")
369        .first()
370        .map(|line| parse_endf_int(line, 2))
371        .transpose()?
372        && lrp == 2
373    {
374        return Err(EndfParseError::UnsupportedFormat(
375            "LRP=2: File 3 holds the whole cross section and its resonance parameters are not \
376             to be added to it; only LRP=1 evaluations are supported"
377                .into(),
378        ));
379    }
380    Ok(ResonanceData {
381        isotope,
382        za,
383        awr,
384        smooth: parse_file3(endf_text, mat)?,
385        ranges: all_ranges,
386    })
387}
388
389fn section_151<'a>(endf_text: &'a str, mf: &str) -> Vec<&'a str> {
390    endf_text
391        .lines()
392        .filter(|line| line.len() >= 75 && line[70..72].trim() == mf && &line[72..75] == "151")
393        .collect()
394}
395
396/// The lines of section `mt` of file `mf` of material `mat`.
397fn section<'a>(endf_text: &'a str, mat: &str, mf: &str, mt: &str) -> Vec<&'a str> {
398    endf_text
399        .lines()
400        .filter(|line| {
401            line.len() >= 75
402                && line[66..70].trim() == mat
403                && line[70..72].trim() == mf
404                && line[72..75].trim() == mt
405        })
406        .collect()
407}
408
409/// File 3's cross sections of material `mat` for the reactions
410/// [`SmoothCrossSections`] holds, each the one TAB1 record that follows its
411/// section's HEAD record.
412fn parse_file3(endf_text: &str, mat: &str) -> Result<SmoothCrossSections, EndfParseError> {
413    let table = |mt: &str| -> Result<Option<Tab1>, EndfParseError> {
414        let lines = section(endf_text, mat, "3", mt);
415        if lines.is_empty() {
416            return Ok(None);
417        }
418        let mut pos = 1;
419        let table = parse_tab1_with(&lines, &mut pos, true)?;
420        if pos < lines.len() {
421            return Err(EndfParseError::UnsupportedFormat(format!(
422                "MF=3 MT={mt} of MAT {mat} holds records after its table"
423            )));
424        }
425        Ok(Some(table))
426    };
427    Ok(SmoothCrossSections {
428        total: table("1")?,
429        elastic: table("2")?,
430        capture: table("102")?,
431        fission: table("18")?,
432    })
433}
434
435const FILE32_TOLERANCE: f64 = 1e-5;
436
437/// The covariance, from File 32 (MT=151), of the energy, neutron width and
438/// capture width of each resonance of `data` with an energy in `window_ev`
439/// that File 32 lists with the same spin and an energy and widths that agree
440/// to a relative 1e-5.  A resonance listed in several short-range
441/// sections has their contributions summed; resonances outside the window,
442/// or that File 32 does not list, are left out.  `None` when the text has no
443/// File 32 or it lists no resonance in the window.
444///
445/// # Errors
446/// [`EndfParseError::InvalidNumber`] for an empty window or one with a NaN
447/// bound.  [`EndfParseError::UnsupportedFormat`] for a File 32 that is not
448/// of the isotope `data` holds, or not of its resolved Reich–Moore ranges in
449/// the general form (LCOMP=1) over each resonance's energy, neutron width and
450/// capture width (MPAR=3), or that gives a scattering-radius covariance
451/// (NRO≠0 or ISR≠0) or long-range sections, or an unresolved range other
452/// than a last one of LRF=1, holds a number that is not finite or records
453/// past its last range, or lists a resonance in the window
454/// that matches no Reich–Moore resonance of `data`, more than one, or one its
455/// block already lists.  The record errors of [`parse_endf_file2`].
456///
457/// Reference: ENDF-6 Formats Manual (ENDF-102, 2023), §32.2.2.2 for the
458/// layout and §32.3 for the correspondence with File 2 and the summing of a
459/// resonance listed in several blocks.
460pub fn parse_resonance_covariance(
461    endf_text: &str,
462    data: &ResonanceData,
463    window_ev: std::ops::RangeInclusive<f64>,
464) -> Result<Option<ResonanceCovariance>, EndfParseError> {
465    if window_ev.is_empty() {
466        return Err(EndfParseError::InvalidNumber(format!(
467            "the energy window {window_ev:?} must run from a lower to a higher energy"
468        )));
469    }
470    let lines = section_151(endf_text, "32");
471    if lines.is_empty() {
472        return Ok(None);
473    }
474    let known: Vec<([usize; 3], &Resonance)> = data
475        .ranges
476        .iter()
477        .enumerate()
478        .filter(|(_, range)| range.formalism == ResonanceFormalism::ReichMoore)
479        .flat_map(|(a, range)| {
480            range
481                .l_groups
482                .iter()
483                .enumerate()
484                .flat_map(move |(b, group)| {
485                    let resonances = group.resonances.iter().enumerate();
486                    resonances.map(move |(c, r)| ([a, b, c], r))
487                })
488        })
489        .collect();
490    let agree = |a: [f64; 3], b: [f64; 3]| {
491        a.iter()
492            .zip(b)
493            .all(|(a, b)| (a - b).abs() <= FILE32_TOLERANCE * a.abs())
494    };
495    let pos = &mut 0;
496    let (head, isotope) = (parse_cont(&lines, pos)?, parse_cont(&lines, pos)?);
497    if [head.c1, isotope.c1] != [f64::from(data.za); 2] || head.n1 != 1 {
498        return Err(EndfParseError::UnsupportedFormat(format!(
499            "File 32 is read for the one isotope `data` holds, ZA={}; got ZA={}, ZAI={}, NIS={}",
500            data.za, head.c1, isotope.c1, head.n1
501        )));
502    }
503    let ranges = checked_count(isotope.n1, "NER")?;
504    let (mut resonances, mut entries) = (Vec::new(), Vec::new());
505    for r in 0..ranges {
506        let range = parse_cont(&lines, pos)?;
507        if range.l1 == 2 && range.l2 == 1 && r + 1 == ranges {
508            let groups = checked_count(parse_cont(&lines, pos)?.n1, "NLS")?;
509            for _ in 0..=groups {
510                let list = parse_cont(&lines, pos)?;
511                parse_list_values(&lines, pos, checked_count(list.n1, "NPL")?)?;
512            }
513            break;
514        }
515        let (form, sections) = (parse_cont(&lines, pos)?, parse_cont(&lines, pos)?);
516        if range.l1 != 1
517            || range.l2 != 3
518            || range.n1 != 0
519            || form.l2 != 1
520            || form.n2 != 0
521            || sections.n2 != 0
522        {
523            return Err(EndfParseError::UnsupportedFormat(format!(
524                "File 32 is read for one isotope's resolved Reich-Moore ranges in the \
525                 general form, without a scattering-radius covariance or long-range \
526                 sections, followed by at most one unresolved range (LRF=1); got LRU={}, \
527                 LRF={}, NRO={}, LCOMP={}, ISR={}, NLRS={}",
528                range.l1, range.l2, range.n1, form.l2, form.n2, sections.n2
529            )));
530        }
531        for _ in 0..checked_count(sections.n1, "NSRS")? {
532            let list = parse_cont(&lines, pos)?;
533            let count = checked_count(list.n2, "NRB")?;
534            let n = 3 * count;
535            let length = 6 * count + n * (n + 1) / 2;
536            if list.l1 != 3 || usize::try_from(list.n1) != Ok(length) {
537                return Err(EndfParseError::UnsupportedFormat(format!(
538                    "File 32 is read over each resonance's energy, neutron width and capture \
539                     width (MPAR=3, NPL=6*NRB+NVS); got MPAR={}, NPL={}, NRB={count}",
540                    list.l1, list.n1
541                )));
542            }
543            let values = parse_list_values(&lines, pos, length)?;
544            if !values.iter().all(|v| v.is_finite()) {
545                return Err(EndfParseError::UnsupportedFormat(
546                    "File 32 holds a number that is not finite".into(),
547                ));
548            }
549            let (table, packed) = values.split_at(6 * count);
550            let mut slots = Vec::new();
551            for (k, row) in table.chunks(6).enumerate() {
552                let (energy, spin) = (row[0], row[1]);
553                let mut same = known.iter().filter(|(_, r)| {
554                    r.j == spin && agree([r.energy, r.gn, r.gg], [row[0], row[2], row[3]])
555                });
556                let found = match (same.next(), same.next()) {
557                    (Some(&(at, r)), None) => Some((at, r.energy)),
558                    _ => None,
559                };
560                if !window_ev.contains(&found.map_or(energy, |(_, e)| e)) {
561                    continue;
562                }
563                let found = found.map(|(at, _)| at);
564                let Some(at) = found.filter(|at| slots.iter().all(|&(_, s)| resonances[s] != *at))
565                else {
566                    return Err(EndfParseError::UnsupportedFormat(format!(
567                        "File 32 lists a resonance at {energy} eV of spin {spin} that matches no \
568                         Reich-Moore resonance of File 2 of that spin to a relative \
569                         {FILE32_TOLERANCE:e} in energy and widths, more than one, or one its \
570                         block already lists"
571                    )));
572                };
573                if !resonances.contains(&at) {
574                    resonances.push(at);
575                }
576                slots.push((k, resonances.iter().position(|&r| r == at).expect("listed")));
577            }
578            for &(k, a) in &slots {
579                for &(l, b) in &slots {
580                    for (x, y) in (0..3).flat_map(|x| (0..3).map(move |y| (x, y))) {
581                        let (u, v) = ((3 * k + x).min(3 * l + y), (3 * k + x).max(3 * l + y));
582                        entries.push((3 * a + x, 3 * b + y, packed[u * n - u * (u + 1) / 2 + v]));
583                    }
584                }
585            }
586        }
587    }
588    if *pos != lines.len() {
589        return Err(EndfParseError::UnsupportedFormat(
590            "File 32 holds records past its last range".into(),
591        ));
592    }
593    let n = 3 * resonances.len();
594    if n == 0 {
595        return Ok(None);
596    }
597    let mut covariance = vec![0.0; n * n];
598    for (x, y, value) in entries {
599        covariance[x * n + y] += value;
600    }
601    Ok(Some(ResonanceCovariance {
602        resonances,
603        covariance: covariance.into(),
604    }))
605}
606
607/// Shared context for ENDF range parsers.
608///
609/// Groups the file-position state (`lines`, `pos`) with the fields from the
610/// range CONT record that every range parser needs, eliminating long argument
611/// lists.
612struct RangeParseContext<'a> {
613    lines: &'a [&'a str],
614    pos: &'a mut usize,
615    energy_low: f64,
616    energy_high: f64,
617    naps: i32,
618    ap_table: Option<Tab1>,
619}
620
621/// Parse a Breit-Wigner (SLBW or MLBW) resolved resonance range.
622///
623/// ENDF-6 File 2, LRF=1 (SLBW) / LRF=2 (MLBW):
624/// - CONT: SPI, AP, 0, 0, NLS, 0
625/// - For each L-value:
626///   - CONT: AWRI, 0.0, L, 0, 6*NRS, NRS
627///   - LIST: NRS resonances, each 6 values: ER, AJ, GT, GN, GG, GF
628///
629/// Reference: ENDF-6 Formats Manual Section 2.2.1.1
630fn parse_bw_range(
631    ctx: &mut RangeParseContext<'_>,
632    formalism: ResonanceFormalism,
633) -> Result<ResonanceRange, EndfParseError> {
634    // CONT: SPI, AP, 0, 0, NLS, 0
635    // ENDF AP is in 10⁻¹² cm; convert to fm (×10).
636    let cont = parse_cont(ctx.lines, ctx.pos)?;
637    let target_spin = cont.c1;
638    let scattering_radius = cont.c2 * ENDF_RADIUS_TO_FM;
639    let nls = checked_count(cont.n1, "NLS")?; // number of L-values
640
641    // Zero NLS means a resolved range with no L-groups: out of spec
642    // (ENDF-6 §2.2.1.1 requires NLS >= 1; NLS=0 belongs to LRU=0
643    // scattering-radius-only stanzas) and silently inert if accepted —
644    // the range would count as evaluable yet contribute zero cross-section
645    // everywhere. Mirrors the NJS=0 guards in the URR skip paths.
646    if nls == 0 {
647        return Err(EndfParseError::UnsupportedFormat(format!(
648            "{formalism:?} range: NLS=0 (ENDF-6 §2.2.1.1 requires at least one \
649             L-group in a resolved range; NLS=0 is reserved for LRU=0)"
650        )));
651    }
652
653    let mut l_groups = Vec::with_capacity(nls);
654
655    for _ in 0..nls {
656        // CONT: AWRI, QX, L, LRX, 6*NRS, NRS
657        let l_cont = parse_cont(ctx.lines, ctx.pos)?;
658        let awr_l = l_cont.c1;
659        let qx = l_cont.c2; // Q-value for competitive width (eV)
660        // Validate L is non-negative: negative L1 wraps to a huge u32.
661        if l_cont.l1 < 0 {
662            return Err(EndfParseError::UnsupportedFormat(format!(
663                "BW range: negative L={}",
664                l_cont.l1
665            )));
666        }
667        let l_val = l_cont.l1 as u32;
668        let lrx = l_cont.l2; // competitive width flag
669        let n1 = checked_count(l_cont.n1, "N1")?; // should be 6*NRS
670        let nrs = checked_count(l_cont.n2, "NRS")?; // number of resonances
671
672        // Validate N1 == 6*NRS: a mismatch means the record is malformed
673        // and reading N1 values would over-/under-consume lines.
674        if n1 != 6 * nrs {
675            return Err(EndfParseError::UnsupportedFormat(format!(
676                "BW range L={l_val}: N1={n1} != 6*NRS={} (NRS={nrs})",
677                6 * nrs
678            )));
679        }
680
681        let mut resonances = Vec::with_capacity(nrs);
682
683        // Each resonance is 6 values on one line (or spanning lines).
684        // In ENDF format, LIST records pack 6 values per line.
685        let total_values = nrs * 6;
686        let values = parse_list_values(ctx.lines, ctx.pos, total_values)?;
687
688        for i in 0..nrs {
689            let base = i * 6;
690            resonances.push(Resonance {
691                energy: values[base],  // ER
692                j: values[base + 1],   // AJ
693                gn: values[base + 3],  // GN (neutron width)
694                gg: values[base + 4],  // GG (gamma width)
695                gfa: values[base + 5], // GF (fission width)
696                gfb: 0.0,              // Not used in BW
697                                       // GT (values[base+2]) is not stored: with LRX = 0 it is
698                                       // GN + GG + GF, and a range with LRX ≠ 0 is not evaluated.
699            });
700        }
701
702        l_groups.push(LGroup {
703            l: l_val,
704            awr: awr_l,
705            apl: 0.0, // Not in BW format
706            qx,
707            lrx,
708            resonances,
709        });
710    }
711
712    // A resolved range whose L-groups are all empty (every NRS=0) is accepted
713    // by reference readers (SAMMY, OpenScale), so it is NOT a hard error here.
714    // NEREIDS treats it as a placeholder, although SAMMY gives its channels
715    // hard-sphere scattering: `is_evaluable()` classifies it non-evaluable,
716    // a warn-and-skip placeholder like
717    // LRF=7/LRU=2/LRU=0, so a mixed file keeps its populated ranges, and a
718    // file where this is the only range fails the no-evaluable-content guard
719    // (fail-closed beats silently reporting zero as physics).
720
721    Ok(ResonanceRange {
722        energy_low: ctx.energy_low,
723        energy_high: ctx.energy_high,
724        resolved: true,
725        formalism,
726        target_spin,
727        scattering_radius,
728        naps: ctx.naps,
729        ap_table: ctx.ap_table.take(),
730        l_groups,
731        r_external: vec![],
732        r_matrix_limited: None,
733    })
734}
735
736/// Parse a Reich-Moore resolved resonance range.
737///
738/// ENDF-6 File 2, LRF=2:
739/// ENDF-6 File 2, LRF=3 (Reich-Moore):
740/// - CONT: SPI, AP, 0, 0, NLS, 0
741/// - For each L-value:
742///   - CONT: AWRI, APL, L, 0, 6*NRS, NRS
743///   - LIST: NRS resonances, each 6 values: ER, AJ, GN, GG, GFA, GFB
744///
745/// Reference: ENDF-6 Formats Manual Section 2.2.1.3
746/// Reference: SAMMY manual Section 2 (R-matrix theory)
747fn parse_reich_moore_range(
748    ctx: &mut RangeParseContext<'_>,
749) -> Result<ResonanceRange, EndfParseError> {
750    // CONT: SPI, AP, 0, 0, NLS, 0
751    // ENDF AP is in 10⁻¹² cm; convert to fm (×10).
752    let cont = parse_cont(ctx.lines, ctx.pos)?;
753    let target_spin = cont.c1;
754    let scattering_radius = cont.c2 * ENDF_RADIUS_TO_FM;
755    let nls = checked_count(cont.n1, "NLS")?; // number of L-values
756
757    // Zero NLS means a resolved range with no L-groups: out of spec
758    // (ENDF-6 §2.2.1.1 requires NLS >= 1; NLS=0 belongs to LRU=0
759    // scattering-radius-only stanzas) and silently inert if accepted —
760    // the range would count as evaluable yet contribute zero cross-section
761    // everywhere. Mirrors the NJS=0 guards in the URR skip paths.
762    if nls == 0 {
763        return Err(EndfParseError::UnsupportedFormat(
764            "Reich-Moore range: NLS=0 (ENDF-6 §2.2.1.1 requires at least one \
765             L-group in a resolved range; NLS=0 is reserved for LRU=0)"
766                .to_string(),
767        ));
768    }
769
770    let mut l_groups = Vec::with_capacity(nls);
771
772    for _ in 0..nls {
773        // CONT: AWRI, APL, L, 0, 6*NRS, NRS
774        let l_cont = parse_cont(ctx.lines, ctx.pos)?;
775        let awr_l = l_cont.c1;
776        let apl = l_cont.c2 * ENDF_RADIUS_TO_FM; // L-dependent scattering radius
777        // Validate L is non-negative: negative L1 wraps to a huge u32.
778        if l_cont.l1 < 0 {
779            return Err(EndfParseError::UnsupportedFormat(format!(
780                "Reich-Moore range: negative L={}",
781                l_cont.l1
782            )));
783        }
784        let l_val = l_cont.l1 as u32;
785        let n1 = checked_count(l_cont.n1, "N1")?; // should be 6*NRS
786        let nrs = checked_count(l_cont.n2, "NRS")?; // number of resonances
787
788        // Validate N1 == 6*NRS: a mismatch means the record is malformed
789        // and reading N1 values would over-/under-consume lines.
790        if n1 != 6 * nrs {
791            return Err(EndfParseError::UnsupportedFormat(format!(
792                "Reich-Moore range L={l_val}: N1={n1} != 6*NRS={} (NRS={nrs})",
793                6 * nrs
794            )));
795        }
796
797        let mut resonances = Vec::with_capacity(nrs);
798
799        // Each resonance is 6 values: ER, AJ, GN, GG, GFA, GFB
800        let total_values = nrs * 6;
801        let values = parse_list_values(ctx.lines, ctx.pos, total_values)?;
802
803        for i in 0..nrs {
804            let base = i * 6;
805            resonances.push(Resonance {
806                energy: values[base],  // ER (eV)
807                j: values[base + 1],   // AJ (total J)
808                gn: values[base + 2],  // GN (neutron width, eV)
809                gg: values[base + 3],  // GG (gamma width, eV)
810                gfa: values[base + 4], // GFA (fission width 1, eV)
811                gfb: values[base + 5], // GFB (fission width 2, eV)
812            });
813        }
814
815        l_groups.push(LGroup {
816            l: l_val,
817            awr: awr_l,
818            apl,
819            qx: 0.0, // Not used in Reich-Moore
820            lrx: 0,  // Not used in Reich-Moore
821            resonances,
822        });
823    }
824
825    // A resolved range whose L-groups are all empty (every NRS=0) is accepted
826    // by reference readers (SAMMY, OpenScale), so it is NOT a hard error here.
827    // NEREIDS treats it as a placeholder, although SAMMY gives its channels
828    // hard-sphere scattering: `is_evaluable()` classifies it non-evaluable,
829    // a warn-and-skip placeholder like
830    // LRF=7/LRU=2/LRU=0, so a mixed file keeps its populated ranges, and a
831    // file where this is the only range fails the no-evaluable-content guard
832    // (fail-closed beats silently reporting zero as physics).
833
834    Ok(ResonanceRange {
835        energy_low: ctx.energy_low,
836        energy_high: ctx.energy_high,
837        resolved: true,
838        formalism: ResonanceFormalism::ReichMoore,
839        target_spin,
840        scattering_radius,
841        naps: ctx.naps,
842        ap_table: ctx.ap_table.take(),
843        l_groups,
844        r_external: vec![],
845        r_matrix_limited: None,
846    })
847}
848
849/// Parse an R-Matrix Limited (LRF=7) resolved range in the Reich-Moore form.
850///
851/// Accepts KRM=3 with widths in eV (IFG=0), KRL=0, hard-sphere phase shifts
852/// (KPS=0), constant AP (NRO=0), and background R-functions with LBK=0 or 2.
853///
854/// # Errors
855/// Returns [`EndfParseError::UnsupportedFormat`] for a flag outside that set, a
856/// fission pair, a shift factor on a channel that closes inside the range, and
857/// any record whose counts or values are inconsistent.
858///
859/// # References
860/// ENDF-6 Formats Manual, ENDF-102 (2023), §2.2.1.6 and Appendix D.1.7.
861fn parse_rmatrix_limited_range(
862    ctx: &mut RangeParseContext<'_>,
863) -> Result<ResonanceRange, EndfParseError> {
864    if ctx.ap_table.is_some() {
865        return Err(rml_error(
866            "NRO=1 (energy-dependent AP) is not supported; LRF=7 gives radii per channel",
867        ));
868    }
869
870    // CONT: [SPI, AP, IFG, KRM, NJS, KRL]; AP in 10⁻¹² cm.
871    let cont = parse_cont(ctx.lines, ctx.pos)?;
872    let target_spin = cont.c1;
873    let scattering_radius = cont.c2 * ENDF_RADIUS_TO_FM;
874    if cont.l1 != 0 {
875        return Err(rml_error(format!(
876            "IFG={} (reduced-width amplitudes) is not supported; only IFG=0 (widths in eV)",
877            cont.l1
878        )));
879    }
880    if cont.l2 != 3 {
881        return Err(rml_error(format!(
882            "KRM={} is not supported; only KRM=3 (Reich-Moore) is evaluated",
883            cont.l2
884        )));
885    }
886    let njs = checked_count(cont.n1, "NJS")?;
887    if cont.n2 != 0 {
888        return Err(rml_error(format!(
889            "KRL={} (relativistic kinematics) is not supported; only KRL=0",
890            cont.n2
891        )));
892    }
893
894    // LIST: [0, 0, NPP, 0, 12*NPP, 2*NPP] with 12 values per pair.
895    let pair_head = parse_cont(ctx.lines, ctx.pos)?;
896    let npp = checked_count(pair_head.l1, "NPP")?;
897    let npl = checked_count(pair_head.n1, "NPL")?;
898    if npl != 12 * npp {
899        return Err(rml_error(format!(
900            "particle-pair LIST: NPL={npl} != 12*NPP={}",
901            12 * npp
902        )));
903    }
904    let pair_values = parse_list_values(ctx.lines, ctx.pos, npl)?;
905    let pairs = pair_values
906        .chunks_exact(12)
907        .enumerate()
908        .map(|(i, values)| parse_particle_pair(i + 1, values))
909        .collect::<Result<Vec<_>, _>>()?;
910
911    let photon_pair = sole_index(&pairs, |p| p.mass_a == 0.0 && p.mt == 102).ok_or_else(|| {
912        rml_error("KRM=3 needs exactly one photon pair (MA=0, MT=102) for the eliminated channel")
913    })?;
914    let incident_pair = sole_index(&pairs, |p| p.mt == 2)
915        .ok_or_else(|| rml_error("exactly one particle pair must have MT=2 (neutron + target)"))?;
916    let incident = &pairs[incident_pair];
917    if incident.mass_a != 1.0 || incident.charge_a != 0.0 || incident.q_value != 0.0 {
918        return Err(rml_error(format!(
919            "the MT=2 pair must be a neutron with Q=0 (MA={}, ZA={}, Q={})",
920            incident.mass_a, incident.charge_a, incident.q_value
921        )));
922    }
923    for (i, pair) in pairs.iter().enumerate() {
924        let threshold = pair.threshold(incident);
925        if pair.shift && threshold > ctx.energy_low {
926            return Err(rml_error(format!(
927                "particle pair {}: SHF=1 on a channel with threshold {threshold:.6e} eV inside \
928                 the range is not supported (closed-channel shift factor)",
929                i + 1
930            )));
931        }
932    }
933
934    let mut spin_groups = Vec::with_capacity(njs);
935    for group in 1..=njs {
936        spin_groups.push(parse_rml_spin_group(
937            ctx,
938            group,
939            &pairs,
940            photon_pair,
941            incident_pair,
942        )?);
943    }
944
945    Ok(ResonanceRange {
946        energy_low: ctx.energy_low,
947        energy_high: ctx.energy_high,
948        resolved: true,
949        formalism: ResonanceFormalism::RMatrixLimited,
950        target_spin,
951        scattering_radius,
952        naps: ctx.naps,
953        ap_table: None,
954        l_groups: Vec::new(),
955        r_external: vec![],
956        r_matrix_limited: Some(RmlRange {
957            pairs,
958            incident_pair,
959            photon_pair,
960            spin_groups,
961        }),
962    })
963}
964
965/// Error for an LRF=7 record NEREIDS does not accept.
966fn rml_error(message: impl std::fmt::Display) -> EndfParseError {
967    EndfParseError::UnsupportedFormat(format!("LRF=7 {message}"))
968}
969
970/// Index of the only item matching `predicate`, or `None` for zero or several.
971fn sole_index<T>(items: &[T], predicate: impl Fn(&T) -> bool) -> Option<usize> {
972    let mut found = None;
973    for (i, item) in items.iter().enumerate() {
974        if predicate(item) {
975            if found.is_some() {
976                return None;
977            }
978            found = Some(i);
979        }
980    }
981    found
982}
983
984/// Read an integer stored in an ENDF float field; fractions and non-finite values fail.
985fn rml_integer(value: f64, field: &str, location: &str) -> Result<i64, EndfParseError> {
986    if !value.is_finite() || value.fract() != 0.0 || value.abs() > f64::from(MAX_ENDF_COUNT) {
987        return Err(rml_error(format!(
988            "{location}: {field}={value} is not an integer"
989        )));
990    }
991    Ok(value as i64)
992}
993
994/// Parse one 12-value particle-pair entry, `number` counting from 1.
995///
996/// PNT=0 takes the ENDF-6 default: no penetrability for MT=102 and MT=19,
997/// penetrability for every other MT.
998fn parse_particle_pair(number: usize, values: &[f64]) -> Result<ParticlePair, EndfParseError> {
999    let location = format!("particle pair {number}");
1000    let &[ma, mb, za, zb, ia, ib, q, pnt, shf, mt, _pa, _pb] = values else {
1001        return Err(rml_error(format!("{location}: expected 12 values")));
1002    };
1003    for (field, value) in [
1004        ("MA", ma),
1005        ("MB", mb),
1006        ("ZA", za),
1007        ("ZB", zb),
1008        ("IA", ia),
1009        ("IB", ib),
1010        ("Q", q),
1011    ] {
1012        if !value.is_finite() {
1013            return Err(rml_error(format!(
1014                "{location}: {field}={value} is not finite"
1015            )));
1016        }
1017    }
1018    let pnt = rml_integer(pnt, "PNT", &location)?;
1019    let shf = rml_integer(shf, "SHF", &location)?;
1020    let mt = rml_integer(mt, "MT", &location)?;
1021    if mt <= 0 {
1022        return Err(rml_error(format!(
1023            "{location}: MT={mt} is not a reaction type"
1024        )));
1025    }
1026    let mt = mt as u32;
1027    let penetrability = match pnt {
1028        1 => true,
1029        -1 => false,
1030        0 => !(mt == 102 || mt == 19),
1031        _ => {
1032            return Err(rml_error(format!(
1033                "{location}: PNT={pnt} is not defined (ENDF-6 allows -1, 0 and 1)"
1034            )));
1035        }
1036    };
1037    let shift = match shf {
1038        0 => false,
1039        1 => true,
1040        _ => {
1041            return Err(rml_error(format!(
1042                "{location}: SHF={shf} is not defined (ENDF-6 allows 0 and 1)"
1043            )));
1044        }
1045    };
1046    if ma == 0.0 && penetrability {
1047        return Err(rml_error(format!(
1048            "{location}: a photon pair (MA=0) cannot calculate the penetrability (PNT={pnt}, MT={mt})"
1049        )));
1050    }
1051    if shift && !penetrability {
1052        return Err(rml_error(format!(
1053            "{location}: SHF=1 needs the penetrability calculated (PNT={pnt}, MT={mt})"
1054        )));
1055    }
1056    if penetrability {
1057        let reduced_mass = ma * mb / (ma + mb);
1058        if !(ma > 0.0 && mb > 0.0 && reduced_mass.is_finite() && reduced_mass > 0.0) {
1059            return Err(rml_error(format!(
1060                "{location}: a penetrating pair needs positive masses (MA={ma}, MB={mb})"
1061            )));
1062        }
1063    }
1064    if matches!(mt, 18..=21 | 38) {
1065        return Err(rml_error(format!(
1066            "{location}: fission pair (MT={mt}) is not supported"
1067        )));
1068    }
1069    Ok(ParticlePair {
1070        mass_a: ma,
1071        mass_b: mb,
1072        charge_a: za,
1073        charge_b: zb,
1074        spin_a: ia,
1075        spin_b: ib,
1076        q_value: q,
1077        mt,
1078        penetrability,
1079        shift,
1080    })
1081}
1082
1083/// Parse one spin group: channel LIST, resonance LIST and KBK background records.
1084fn parse_rml_spin_group(
1085    ctx: &mut RangeParseContext<'_>,
1086    group: usize,
1087    pairs: &[ParticlePair],
1088    photon_pair: usize,
1089    incident_pair: usize,
1090) -> Result<RmlSpinGroup, EndfParseError> {
1091    let location = format!("spin group {group}");
1092
1093    // LIST: [AJ, PJ, KBK, KPS, 6*NCH, NCH] with 6 values per channel.
1094    let head = parse_cont(ctx.lines, ctx.pos)?;
1095    let (aj, pj) = (head.c1, head.c2);
1096    if head.l2 != 0 {
1097        return Err(rml_error(format!(
1098            "{location}: KPS={} (tabulated phase shifts) is not supported",
1099            head.l2
1100        )));
1101    }
1102    let kbk = checked_count(head.l1, "KBK")?;
1103    let nch = checked_count(head.n2, "NCH")?;
1104    let npl = checked_count(head.n1, "NPL")?;
1105    if npl != 6 * nch {
1106        return Err(rml_error(format!(
1107            "{location}: channel LIST NPL={npl} != 6*NCH={}",
1108            6 * nch
1109        )));
1110    }
1111    if !aj.is_finite() || (2.0 * aj).fract() != 0.0 {
1112        return Err(rml_error(format!(
1113            "{location}: AJ={aj} is not a multiple of 1/2"
1114        )));
1115    }
1116    let parity = if aj < 0.0 || (aj == 0.0 && pj < 0.0) {
1117        -1
1118    } else {
1119        1
1120    };
1121
1122    let channel_values = parse_list_values(ctx.lines, ctx.pos, npl)?;
1123    let mut channels = Vec::with_capacity(nch);
1124    for (c, v) in channel_values.chunks_exact(6).enumerate() {
1125        let channel_location = format!("{location} channel {}", c + 1);
1126        let ppi = rml_integer(v[0], "PPI", &channel_location)?;
1127        if ppi < 1 || ppi as usize > pairs.len() {
1128            return Err(rml_error(format!(
1129                "{channel_location}: PPI={ppi} is out of range 1..={}",
1130                pairs.len()
1131            )));
1132        }
1133        let l = rml_integer(v[1], "L", &channel_location)?;
1134        if l < 0 {
1135            return Err(rml_error(format!("{channel_location}: L={l} is negative")));
1136        }
1137        let (sch, bnd, ape, apt) = (v[2], v[3], v[4], v[5]);
1138        if ![sch, bnd, ape, apt].iter().all(|x| x.is_finite()) || ape < 0.0 || apt < 0.0 {
1139            return Err(rml_error(format!(
1140                "{channel_location}: SCH={sch}, BND={bnd}, APE={ape}, APT={apt} must be finite \
1141                 with non-negative radii"
1142            )));
1143        }
1144        let pair = ppi as usize - 1;
1145        if pairs[pair].penetrability && apt == 0.0 {
1146            return Err(rml_error(format!(
1147                "{channel_location}: APT=0 for a pair that calculates the penetrability"
1148            )));
1149        }
1150        channels.push(RmlChannel {
1151            pair,
1152            l: l as u32,
1153            channel_spin: sch,
1154            boundary: bnd,
1155            radius_phase: ape * ENDF_RADIUS_TO_FM,
1156            radius_true: apt * ENDF_RADIUS_TO_FM,
1157        });
1158    }
1159    if channels.iter().filter(|c| c.pair == photon_pair).count() != 1 {
1160        return Err(rml_error(format!(
1161            "{location}: needs exactly one channel of the photon pair (the eliminated channel)"
1162        )));
1163    }
1164    if !channels.iter().any(|c| c.pair == incident_pair) {
1165        return Err(rml_error(format!(
1166            "{location}: has no neutron entrance channel"
1167        )));
1168    }
1169
1170    // LIST: [0, 0, 0, NRS, 6*NX, NX]; each resonance holds ER and NCH widths on
1171    // NCH/6+1 lines (ENDF-102 §2.2.1.6), and NRS=0 still carries one line.
1172    let res_head = parse_cont(ctx.lines, ctx.pos)?;
1173    let nrs = checked_count(res_head.l2, "NRS")?;
1174    let nx = checked_count(res_head.n2, "NX")?;
1175    let res_npl = checked_count(res_head.n1, "NPL")?;
1176    let lines_per_resonance = nch / 6 + 1;
1177    let expected_nx = if nrs == 0 {
1178        1
1179    } else {
1180        nrs * lines_per_resonance
1181    };
1182    if nx != expected_nx || res_npl != 6 * nx {
1183        return Err(rml_error(format!(
1184            "{location}: resonance LIST NX={nx}, NPL={res_npl}; NRS={nrs} and NCH={nch} need \
1185             NX={expected_nx} and NPL={}",
1186            6 * expected_nx
1187        )));
1188    }
1189    let res_values = parse_list_values(ctx.lines, ctx.pos, res_npl)?;
1190    let mut resonances = Vec::with_capacity(nrs);
1191    if nrs > 0 {
1192        for row in res_values.chunks_exact(6 * lines_per_resonance) {
1193            let energy = row[0];
1194            let widths = row[1..=nch].to_vec();
1195            if !energy.is_finite() || !widths.iter().all(|w| w.is_finite()) {
1196                return Err(rml_error(format!(
1197                    "{location}: resonance at ER={energy} has a non-finite value"
1198                )));
1199            }
1200            // A width at the channel threshold has P = 0 and no finite amplitude.
1201            for (channel, &width) in channels.iter().zip(&widths) {
1202                let pair = &pairs[channel.pair];
1203                if pair.penetrability
1204                    && width != 0.0
1205                    && energy == pair.threshold(&pairs[incident_pair])
1206                {
1207                    return Err(rml_error(format!(
1208                        "{location}: resonance at ER={energy} eV sits on the threshold of a \
1209                         penetrating channel with width {width} eV"
1210                    )));
1211                }
1212            }
1213            resonances.push(RmlResonance { energy, widths });
1214        }
1215    }
1216
1217    // KBK records: [0, 0, LCH, LBK, 0, 0] then the LBK body.
1218    let mut background = Vec::new();
1219    let mut seen = vec![false; nch];
1220    for _ in 0..kbk {
1221        let record = parse_cont(ctx.lines, ctx.pos)?;
1222        let lch = checked_count(record.l1, "LCH")?;
1223        if lch == 0 || lch > nch {
1224            return Err(rml_error(format!(
1225                "{location}: background LCH={lch} is out of range 1..={nch}"
1226            )));
1227        }
1228        let channel = lch - 1;
1229        if seen[channel] {
1230            return Err(rml_error(format!(
1231                "{location}: background given twice for channel {lch}"
1232            )));
1233        }
1234        seen[channel] = true;
1235        if channels[channel].pair == photon_pair {
1236            return Err(rml_error(format!(
1237                "{location}: background on the eliminated photon channel {lch}"
1238            )));
1239        }
1240        match record.l2 {
1241            0 => {}
1242            2 => {
1243                // LIST: [ED, EU, 0, 0, 5, 0 / R0, R1, R2, S0, S1].
1244                let list = parse_cont(ctx.lines, ctx.pos)?;
1245                if list.n1 != 5 {
1246                    return Err(rml_error(format!(
1247                        "{location}: LBK=2 LIST has NPL={}, expected 5",
1248                        list.n1
1249                    )));
1250                }
1251                let v = parse_list_values(ctx.lines, ctx.pos, 5)?;
1252                let (e_down, e_up) = (list.c1, list.c2);
1253                let valid = e_down < ctx.energy_low
1254                    && e_up > ctx.energy_high
1255                    && v.iter().all(|x| x.is_finite());
1256                if !valid {
1257                    return Err(rml_error(format!(
1258                        "{location}: LBK=2 needs finite terms and ED < EL, EU > EH \
1259                         (ED={e_down}, EU={e_up})"
1260                    )));
1261                }
1262                background.push(RmlBackground {
1263                    channel,
1264                    e_down,
1265                    e_up,
1266                    r_con: v[0],
1267                    r_lin: v[1],
1268                    r_quad: v[2],
1269                    s_con: v[3],
1270                    s_lin: v[4],
1271                });
1272            }
1273            1 | 3 => {
1274                return Err(rml_error(format!(
1275                    "{location}: LBK={} background is not supported (only LBK=0 and 2)",
1276                    record.l2
1277                )));
1278            }
1279            lbk => {
1280                return Err(rml_error(format!("{location}: LBK={lbk} is not defined")));
1281            }
1282        }
1283    }
1284
1285    Ok(RmlSpinGroup {
1286        j: aj.abs(),
1287        parity,
1288        channels,
1289        resonances,
1290        background,
1291    })
1292}
1293
1294/// Maximum sane ENDF count value.
1295///
1296/// ENDF files in practice never contain more than ~100k resonances per section.
1297/// Accepting `i32::MAX` would cause enormous allocations (gigabytes) on
1298/// malformed files.  This cap is generous enough for any real evaluation while
1299/// protecting against allocation bombs.
1300const MAX_ENDF_COUNT: i32 = 1_000_000;
1301
1302/// Validate that an ENDF integer count is non-negative and return as `usize`.
1303///
1304/// Malformed records can contain negative counts which, if cast directly to
1305/// `usize`, wrap to huge values and cause OOM panics in `Vec::with_capacity`
1306/// or `parse_list_values`.
1307fn checked_count(value: i32, label: &str) -> Result<usize, EndfParseError> {
1308    if value < 0 {
1309        return Err(EndfParseError::UnsupportedFormat(format!(
1310            "Negative ENDF count: {label}={value}"
1311        )));
1312    }
1313    if value > MAX_ENDF_COUNT {
1314        return Err(EndfParseError::UnsupportedFormat(format!(
1315            "ENDF count too large: {label}={value} (maximum {MAX_ENDF_COUNT})"
1316        )));
1317    }
1318    Ok(value as usize)
1319}
1320
1321// ---------------------------------------------------------------------------
1322// Low-level ENDF line parsing helpers
1323// ---------------------------------------------------------------------------
1324
1325/// Parsed CONT (control) record with 2 floats, 4 integers.
1326struct ContRecord {
1327    c1: f64,
1328    c2: f64,
1329    l1: i32,
1330    l2: i32,
1331    n1: i32,
1332    n2: i32,
1333}
1334
1335/// Parse a CONT record from the current line.
1336fn parse_cont(lines: &[&str], pos: &mut usize) -> Result<ContRecord, EndfParseError> {
1337    if *pos >= lines.len() {
1338        return Err(EndfParseError::UnexpectedEof(
1339            "Expected CONT record but reached end of data".to_string(),
1340        ));
1341    }
1342    let line = lines[*pos];
1343    *pos += 1;
1344
1345    Ok(ContRecord {
1346        c1: parse_endf_float(line, 0)?,
1347        c2: parse_endf_float(line, 1)?,
1348        l1: parse_endf_int(line, 2)?,
1349        l2: parse_endf_int(line, 3)?,
1350        n1: parse_endf_int(line, 4)?,
1351        n2: parse_endf_int(line, 5)?,
1352    })
1353}
1354
1355/// Parse a LIST of floating-point values spanning multiple lines.
1356///
1357/// ENDF packs 6 values per line. We read ceil(n/6) lines.
1358fn parse_list_values(
1359    lines: &[&str],
1360    pos: &mut usize,
1361    n_values: usize,
1362) -> Result<Vec<f64>, EndfParseError> {
1363    let mut values = Vec::with_capacity(n_values.min(6 * lines.len().saturating_sub(*pos)));
1364    let n_lines = n_values.div_ceil(6);
1365
1366    for _ in 0..n_lines {
1367        if *pos >= lines.len() {
1368            return Err(EndfParseError::UnexpectedEof(
1369                "Expected LIST data but reached end".to_string(),
1370            ));
1371        }
1372        let line = lines[*pos];
1373        *pos += 1;
1374
1375        let remaining = n_values - values.len();
1376        let fields_on_line = remaining.min(6);
1377
1378        for field in 0..fields_on_line {
1379            values.push(parse_endf_float(line, field)?);
1380        }
1381    }
1382
1383    Ok(values)
1384}
1385
1386/// Parse a floating-point value from an 11-character ENDF field.
1387///
1388/// ENDF uses Fortran-style floats that may omit 'E', e.g.:
1389/// - " 1.234567+2" means 1.234567e+2
1390/// - "-3.456789-1" means -3.456789e-1
1391/// - " 0.000000+0" means 0.0
1392fn parse_endf_float(line: &str, field_index: usize) -> Result<f64, EndfParseError> {
1393    let start = field_index * 11;
1394    let end = start + 11;
1395
1396    if line.len() < end {
1397        // Short line — treat as zero.
1398        return Ok(0.0);
1399    }
1400
1401    let field = &line[start..end];
1402    let trimmed = field.trim();
1403
1404    if trimmed.is_empty() {
1405        return Ok(0.0);
1406    }
1407
1408    // Try standard Rust float parsing first.
1409    if let Ok(v) = trimmed.parse::<f64>() {
1410        return Ok(v);
1411    }
1412
1413    // Handle Fortran-style: "1.234567+2" or "-3.456789-1"
1414    // Look for +/- that is NOT the first character and NOT preceded by 'e'/'E'/'d'/'D'.
1415    let bytes = trimmed.as_bytes();
1416    for i in 1..bytes.len() {
1417        if (bytes[i] == b'+' || bytes[i] == b'-')
1418            && bytes[i - 1] != b'e'
1419            && bytes[i - 1] != b'E'
1420            && bytes[i - 1] != b'd'
1421            && bytes[i - 1] != b'D'
1422            && bytes[i - 1] != b'+'
1423            && bytes[i - 1] != b'-'
1424        {
1425            let mantissa = &trimmed[..i];
1426            let exp_slice = &trimmed[i..];
1427            // Strip spaces from the exponent only when present (some ENDF files
1428            // write "+ 4" not "+4").  Avoid allocation on the common path.
1429            let with_e = if exp_slice.contains(' ') {
1430                let exponent: String = exp_slice.chars().filter(|c| !c.is_whitespace()).collect();
1431                format!("{}E{}", mantissa, exponent)
1432            } else {
1433                format!("{}E{}", mantissa, exp_slice)
1434            };
1435            if let Ok(v) = with_e.parse::<f64>() {
1436                return Ok(v);
1437            }
1438        }
1439    }
1440
1441    Err(EndfParseError::InvalidNumber(format!(
1442        "Cannot parse ENDF float: '{}'",
1443        field
1444    )))
1445}
1446
1447/// Parse an integer from an 11-character ENDF field.
1448fn parse_endf_int(line: &str, field_index: usize) -> Result<i32, EndfParseError> {
1449    let start = field_index * 11;
1450    let end = start + 11;
1451
1452    if line.len() < end {
1453        return Ok(0);
1454    }
1455
1456    let field = &line[start..end];
1457    let trimmed = field.trim();
1458
1459    if trimmed.is_empty() {
1460        return Ok(0);
1461    }
1462
1463    // ENDF integers may have a decimal point (e.g., "1.000000+0" for 1).
1464    // Try integer parse first, then float-with-integral-value.
1465    if let Ok(v) = trimmed.parse::<i32>() {
1466        return Ok(v);
1467    }
1468
1469    // Try parsing as float, but reject non-integral values (e.g., "1.9e+0")
1470    // rather than silently truncating.  Use the same ε=1e-6 tolerance as
1471    // `parse_tab1`'s NBT/INT validation so that integer fields stored as
1472    // ENDF floats ("1.000000+0") still round-trip, but a malformed
1473    // "1.900000+0" is surfaced as an InvalidNumber rather than parsed as 1.
1474    if let Ok(v) = parse_endf_float(line, field_index) {
1475        if (v - v.round()).abs() > 1e-6 {
1476            return Err(EndfParseError::InvalidNumber(format!(
1477                "Non-integral value in ENDF int field: '{}' (={})",
1478                field, v
1479            )));
1480        }
1481        return Ok(v.round() as i32);
1482    }
1483
1484    Err(EndfParseError::InvalidNumber(format!(
1485        "Cannot parse ENDF int: '{}'",
1486        field
1487    )))
1488}
1489
1490/// Consume (skip) a URR LFW=1/LRF=1 "Case B" range body (ENDF-6 §2.2.2.1),
1491/// advancing the line cursor past the shared fission-width energy LIST and each
1492/// per-(L,J) LIST record. NEREIDS does not evaluate URR cross sections, so the
1493/// parameter values are discarded; only the record structure is read, which is
1494/// what keeps the line stream aligned for any following range or SEND record.
1495/// Returns the header's `(SPI, AP)` — AP converted to fm — so the caller can
1496/// preserve them on the placeholder range.
1497///
1498/// The per-J control line (carrying `N1 = NE+6`) MUST be consumed before its
1499/// body — omitting it misaligns the stream by one record per J-group. The
1500/// `N1 = NE+6` guard is preserved to catch malformed records.
1501///
1502/// Reference: ENDF-6 §2.2.2.1 Case B; OpenScale `File2.cpp`
1503/// (`lfw==1 && lrf==1` branch) and AMPX `File2Unres.f90` — both OpenScale/AMPX
1504/// readers vendored under SAMMY's external tree, not SAMMY proper.
1505fn skip_urr_lfw1_lrf1(lines: &[&str], pos: &mut usize) -> Result<(f64, f64), EndfParseError> {
1506    // CONT: SPI, AP, LSSF, 0, NE, NLS
1507    let header = parse_cont(lines, pos)?;
1508    let spi = header.c1;
1509    let ap_fm = header.c2 * ENDF_RADIUS_TO_FM;
1510    let ne = checked_count(header.n1, "NE")?;
1511    let nls = checked_count(header.n2, "NLS")?;
1512
1513    // LIST: NE shared fission-width-grid energies.
1514    parse_list_values(lines, pos, ne)?;
1515
1516    for _ in 0..nls {
1517        // CONT: AWRI, 0, L, 0, NJS, 0
1518        let l_cont = parse_cont(lines, pos)?;
1519        let njs = checked_count(l_cont.n1, "NJS")?;
1520
1521        // Zero NJS means no J-groups for this L-value, which is malformed
1522        // (ENDF §2.2.2.1 requires at least one J-group per L-group) and would
1523        // under-consume the body, surfacing as a confusing downstream error.
1524        // Mirrors the NJS=0 guards in `skip_urr_range`.
1525        if njs == 0 {
1526            return Err(EndfParseError::UnsupportedFormat(format!(
1527                "URR LFW=1/LRF=1 L={}: NJS=0 (at least one J-group required)",
1528                l_cont.l1
1529            )));
1530        }
1531
1532        for _ in 0..njs {
1533            // Per-J LIST control: 0.0, 0.0, L, MUF, NE+6, 0.  Consuming this
1534            // control record keeps the line stream aligned — the body follows.
1535            let j_cont = parse_cont(lines, pos)?;
1536            let n1 = checked_count(j_cont.n1, "N1")?;
1537            // SCALE validates this exact relation (File2.cpp: N1-6 == NE).
1538            if n1 != ne + 6 {
1539                return Err(EndfParseError::UnsupportedFormat(format!(
1540                    "URR LFW=1/LRF=1: per-J N1={n1} ≠ NE+6={} (NE={ne})",
1541                    ne + 6
1542                )));
1543            }
1544            // LIST body: [D, AJ, AMUN, GNO, GG, 0] + NE fission widths (discarded).
1545            parse_list_values(lines, pos, n1)?;
1546        }
1547    }
1548
1549    Ok((spi, ap_fm))
1550}
1551
1552/// Consume (skip) a URR (LRU=2) range body of the given LRF, advancing the line
1553/// cursor past every L- and (L,J)-record. NEREIDS does not evaluate URR cross
1554/// sections, so the parameter values are discarded; only the record structure
1555/// is read, which keeps the line stream aligned for any following range or SEND
1556/// record. Returns the header's `(SPI, AP)` — AP converted to fm — so the
1557/// caller can preserve them on the placeholder range.
1558///
1559/// Handles LFW=0/LRF=1 (energy-independent widths) and LRF=2 (tabulated
1560/// widths); LFW=1/LRF=2 is byte-identical to LFW=0/LRF=2 and routes here too.
1561/// LFW=1/LRF=1 (shared-grid "Case B") is handled by `skip_urr_lfw1_lrf1`.
1562///
1563/// The structural guards (N1=6*NJS for LRF=1; INT∈1..=5 and N1=6*(NE+1) for
1564/// LRF=2; NJS>0) are preserved: they catch malformed records that would
1565/// otherwise over-/under-consume lines and misalign the cursor.
1566///
1567/// Reference: ENDF-6 Formats Manual §2.2.2
1568fn skip_urr_range(lines: &[&str], pos: &mut usize, lrf: i32) -> Result<(f64, f64), EndfParseError> {
1569    // CONT: SPI, AP, 0, 0, NLS, 0
1570    let spi_cont = parse_cont(lines, pos)?;
1571    let spi = spi_cont.c1;
1572    let ap_fm = spi_cont.c2 * ENDF_RADIUS_TO_FM;
1573    let nls = checked_count(spi_cont.n1, "NLS")?;
1574
1575    if lrf == 1 {
1576        // LRF=1: energy-independent widths, one LIST block per L covering all J.
1577        for _ in 0..nls {
1578            // CONT: AWRI, 0, L, 0, N1=6*NJS, N2=NJS
1579            let l_cont = parse_cont(lines, pos)?;
1580            if l_cont.l1 < 0 {
1581                return Err(EndfParseError::UnsupportedFormat(format!(
1582                    "URR LRF=1: negative L={}",
1583                    l_cont.l1
1584                )));
1585            }
1586            let l = l_cont.l1 as u32;
1587            let n1 = checked_count(l_cont.n1, "N1")?; // 6*NJS
1588            let njs = checked_count(l_cont.n2, "NJS")?;
1589            if njs == 0 || n1 != 6 * njs {
1590                return Err(EndfParseError::UnsupportedFormat(format!(
1591                    "URR LRF=1 L={l}: N1={n1} ≠ 6×NJS={} (NJS={njs})",
1592                    6 * njs
1593                )));
1594            }
1595            // LIST body: [D, AJ, AMUN, GNO, GG, GF] × NJS (discarded).
1596            parse_list_values(lines, pos, n1)?;
1597        }
1598    } else {
1599        // LRF=2: energy-dependent width tables, one LIST per (L, J).
1600        for _ in 0..nls {
1601            // CONT: AWRI, 0, L, 0, NJS, 0
1602            let l_cont = parse_cont(lines, pos)?;
1603            if l_cont.l1 < 0 {
1604                return Err(EndfParseError::UnsupportedFormat(format!(
1605                    "URR LRF=2: negative L={}",
1606                    l_cont.l1
1607                )));
1608            }
1609            let l = l_cont.l1 as u32;
1610            let njs = checked_count(l_cont.n1, "NJS")?; // N1 = NJS for LRF=2
1611
1612            // Zero NJS means no J-groups for this L-value, which is malformed
1613            // (ENDF §2.2.2.2 requires at least one J-group per L-group).
1614            if njs == 0 {
1615                return Err(EndfParseError::UnsupportedFormat(format!(
1616                    "URR LRF=2 L={l}: NJS=0 (at least one J-group required)"
1617                )));
1618            }
1619
1620            for _ in 0..njs {
1621                // CONT: AJ, 0, INT, 0, N1=6*(NE+1), N2=NE
1622                let j_cont = parse_cont(lines, pos)?;
1623                let int_code = j_cont.l1; // interpolation law (L1 field)
1624                // ENDF-6 §0.5 defines INT codes 1..=5; anything outside that
1625                // range — including negative values and INT=0 or INT≥6 — is a
1626                // malformed record.
1627                if !(1..=5).contains(&int_code) {
1628                    return Err(EndfParseError::UnsupportedFormat(format!(
1629                        "URR LRF=2: INT={int_code} out of spec (expected 1..=5)"
1630                    )));
1631                }
1632                let n1 = checked_count(j_cont.n1, "N1")?; // 6*(NE+1)
1633                let ne = checked_count(j_cont.n2, "NE")?; // NE (number of energy points)
1634
1635                // Validate N1 = 6*(NE+1) before consuming the LIST body so a
1636                // malformed record cannot over-/under-consume lines.
1637                let expected_n1 = 6 * (ne + 1);
1638                if n1 != expected_n1 {
1639                    return Err(EndfParseError::UnsupportedFormat(format!(
1640                        "URR LRF=2: N1={n1} ≠ 6*(NE+1)={expected_n1} (NE={ne})"
1641                    )));
1642                }
1643                // LIST body: DOF row + NE width rows (discarded).
1644                parse_list_values(lines, pos, n1)?;
1645            }
1646        }
1647    }
1648
1649    Ok((spi, ap_fm))
1650}
1651
1652/// Parse a TAB1 record into a `Tab1` interpolation table.
1653///
1654/// ENDF TAB1 layout (Reference: ENDF-6 Formats Manual §0.5):
1655/// ```text
1656/// CONT: [C1, C2, L1, L2, NR, NP]
1657/// NR×2 integer values: (NBT_i, INT_i) pairs  — 6 per line
1658/// NP×2 float values:   (x_i,   y_i)   pairs  — 6 per line
1659/// ```
1660///
1661/// INT codes: 1=histogram, 2=lin-lin, 3=log-x/lin-y, 4=lin-x/log-y, 5=log-log.
1662fn parse_tab1(lines: &[&str], pos: &mut usize) -> Result<Tab1, EndfParseError> {
1663    parse_tab1_with(lines, pos, false)
1664}
1665
1666/// A TAB1 record, with `jumps` read as File 3 is.
1667///
1668/// With `jumps`, points at one x are a jump from the first value to the last,
1669/// which holds from x on, and a point repeated exactly is read once. A region
1670/// left with no interval is dropped.
1671///
1672/// # References
1673/// ENDF-6 Formats Manual (ENDF-102, 2023), §0.5.2.1 and §3.3.1.
1674fn parse_tab1_with(lines: &[&str], pos: &mut usize, jumps: bool) -> Result<Tab1, EndfParseError> {
1675    let cont = parse_cont(lines, pos)?;
1676    let nr = checked_count(cont.n1, "NR")?; // number of interpolation regions
1677    let np = checked_count(cont.n2, "NP")?; // number of data points
1678
1679    // NR=0 is valid ENDF: it means a single implicit interpolation region
1680    // covering all NP points with no explicit boundary record.  The
1681    // evaluate() call will fall through to the `unwrap_or(2)` default in
1682    // interp_code_for_interval(), which correctly returns INT=2 (lin-lin).
1683    // When NR=0, the loop below is a no-op and the interp_raw vec stays empty.
1684
1685    // Read NR×2 integers: (NBT, INT) pairs packed as ENDF floats.
1686    // Validate that values are integers, INT codes are in 1..=5, boundaries
1687    // are strictly increasing, and the last boundary equals NP.
1688    let interp_raw = parse_list_values(lines, pos, nr * 2)?;
1689    let mut boundaries = Vec::with_capacity(nr);
1690    let mut interp_codes = Vec::with_capacity(nr);
1691    for i in 0..nr {
1692        let nbt_raw = interp_raw[i * 2];
1693        let int_raw = interp_raw[i * 2 + 1];
1694
1695        // ENDF stores integers as floats (e.g. "2.000000+0").  They must be
1696        // exact whole numbers.  Use a small epsilon (1e-6) rather than the
1697        // half-unit tolerance 0.5, which would silently accept 1.4 or 2.49.
1698        // NBT is a 1-based index (ENDF §0.5), so 0 is invalid.
1699        if (nbt_raw - nbt_raw.round()).abs() > 1e-6 || nbt_raw < 1.0 {
1700            return Err(EndfParseError::UnsupportedFormat(format!(
1701                "TAB1 NBT[{}] is not a positive integer: {}",
1702                i, nbt_raw
1703            )));
1704        }
1705        if (int_raw - int_raw.round()).abs() > 1e-6 {
1706            return Err(EndfParseError::UnsupportedFormat(format!(
1707                "TAB1 INT[{}] is not an integer: {}",
1708                i, int_raw
1709            )));
1710        }
1711        let int_code = int_raw.round() as u32;
1712        if !(1..=5).contains(&int_code) {
1713            return Err(EndfParseError::UnsupportedFormat(format!(
1714                "TAB1 INT[{}]={} is out of range 1..=5",
1715                i, int_code
1716            )));
1717        }
1718        let nbt = nbt_raw.round() as usize;
1719
1720        // Boundaries must be strictly increasing (ENDF §0.5).
1721        if let Some(&prev) = boundaries.last()
1722            && nbt <= prev
1723        {
1724            return Err(EndfParseError::UnsupportedFormat(format!(
1725                "TAB1 NBT[{}]={} is not greater than NBT[{}]={}",
1726                i,
1727                nbt,
1728                i - 1,
1729                prev
1730            )));
1731        }
1732        boundaries.push(nbt);
1733        interp_codes.push(int_code);
1734    }
1735
1736    // The final boundary must equal NP (ENDF §0.5: last NBT is 1-based index of last point).
1737    if nr > 0 {
1738        let last_nbt = *boundaries.last().unwrap();
1739        if last_nbt != np {
1740            return Err(EndfParseError::UnsupportedFormat(format!(
1741                "TAB1 last NBT={} does not equal NP={}",
1742                last_nbt, np
1743            )));
1744        }
1745    }
1746
1747    if np == 0 {
1748        return Err(EndfParseError::UnsupportedFormat(
1749            "TAB1 NP=0: table must have at least one point".to_string(),
1750        ));
1751    }
1752
1753    // Read NP×2 floats: (E, AP) pairs.
1754    let data_raw = parse_list_values(lines, pos, np * 2)?;
1755    let mut points = Vec::with_capacity(np);
1756    for i in 0..np {
1757        let x = data_raw[i * 2];
1758        let y = data_raw[i * 2 + 1];
1759        if !(x.is_finite() && y.is_finite()) {
1760            return Err(EndfParseError::UnsupportedFormat(format!(
1761                "TAB1 point {i} ({x}, {y}) is not finite"
1762            )));
1763        }
1764        // A jump has two values; one between them holds over no interval.
1765        if jumps
1766            && let &[.., (x_first, _), (x_last, y_last)] = points.as_slice()
1767            && x_first == x
1768            && x_last == x
1769            && y_last != y
1770        {
1771            points.pop();
1772            drop_point(&mut boundaries, &mut interp_codes, points.len());
1773        }
1774        if jumps && points.last() == Some(&(x, y)) {
1775            drop_point(&mut boundaries, &mut interp_codes, points.len());
1776            continue;
1777        }
1778        // x-values must be strictly increasing, or repeat once at a jump;
1779        // Tab1::evaluate() relies on this.
1780        let jump = jumps && points.last().is_some_and(|&(x_prev, _)| x == x_prev);
1781        if let Some(&(x_prev, _)) = points.last()
1782            && x <= x_prev
1783            && !jump
1784        {
1785            return Err(EndfParseError::UnsupportedFormat(format!(
1786                "TAB1 x[{}]={} is not greater than x[{}]={} (x must be strictly increasing)",
1787                i,
1788                x,
1789                i - 1,
1790                x_prev
1791            )));
1792        }
1793        points.push((x, y));
1794    }
1795
1796    Ok(Tab1 {
1797        boundaries,
1798        interp_codes,
1799        points,
1800    })
1801}
1802
1803/// Remove the 0-based point `index` from a TAB1's interpolation regions, and
1804/// drop a region it leaves with no interval.
1805fn drop_point(boundaries: &mut Vec<usize>, interp_codes: &mut Vec<u32>, index: usize) {
1806    for nbt in boundaries.iter_mut().filter(|nbt| **nbt > index) {
1807        *nbt -= 1;
1808    }
1809    // A region left with no interval: NBT = 1 or NBT equal to the one before.
1810    let empty = if boundaries.first() == Some(&1) {
1811        Some(0)
1812    } else {
1813        (1..boundaries.len()).find(|&r| boundaries[r] == boundaries[r - 1])
1814    };
1815    if let Some(empty) = empty {
1816        boundaries.remove(empty);
1817        interp_codes.remove(empty);
1818    }
1819}
1820
1821/// Errors from ENDF parsing.
1822#[derive(Debug, thiserror::Error)]
1823pub enum EndfParseError {
1824    #[error("Missing section: {0}")]
1825    MissingSection(String),
1826
1827    #[error("Unsupported format: {0}")]
1828    UnsupportedFormat(String),
1829
1830    #[error("Invalid number: {0}")]
1831    InvalidNumber(String),
1832
1833    #[error("Unexpected end of file: {0}")]
1834    UnexpectedEof(String),
1835
1836    #[error("Invalid isotope: {0}")]
1837    InvalidIsotope(#[from] nereids_core::error::NereidsError),
1838}
1839
1840#[cfg(test)]
1841mod tests {
1842    use super::*;
1843
1844    #[test]
1845    fn test_parse_endf_float_standard() {
1846        // ENDF fields are exactly 11 chars wide, no separators.
1847        // " 1.23456+2" in 11 chars = " 1.23456+02" (Fortran E11.4 style)
1848        //  01234567890  (field 0: cols 0-10, field 1: cols 11-21, etc.)
1849        let line = " 1.23456+02 2.34567-01 0.00000+00                                            ";
1850        assert!((parse_endf_float(line, 0).unwrap() - 123.456).abs() < 0.01);
1851        assert!((parse_endf_float(line, 1).unwrap() - 0.234567).abs() < 1e-6);
1852        assert!((parse_endf_float(line, 2).unwrap() - 0.0).abs() < 1e-10);
1853    }
1854
1855    #[test]
1856    fn test_parse_endf_float_with_e() {
1857        // 11-char fields: "1.23456E+02" "2.34567E-01"
1858        let line = "1.23456E+022.34567E-01                                                       ";
1859        assert!((parse_endf_float(line, 0).unwrap() - 123.456).abs() < 0.01);
1860        assert!((parse_endf_float(line, 1).unwrap() - 0.234567).abs() < 1e-6);
1861    }
1862
1863    #[test]
1864    fn test_parse_endf_float_negative() {
1865        let line = "-1.23456+02-2.34567-01                                                       ";
1866        assert!((parse_endf_float(line, 0).unwrap() - (-123.456)).abs() < 0.01);
1867        assert!((parse_endf_float(line, 1).unwrap() - (-0.234567)).abs() < 1e-6);
1868    }
1869
1870    /// Fortran exponents with a space between the sign and digit — e.g. "9.22330+ 4"
1871    /// — appear in some older ENDF evaluations (observed in SAMMY tr149/t149a.endf
1872    /// for U-233).  The parser strips the space before parsing the exponent.
1873    #[test]
1874    fn test_parse_endf_float_spaced_exponent() {
1875        // " 9.22330+ 4" occupies 11 chars: space before mantissa, space before digit
1876        let line =
1877            " 9.22330+ 4 1.23400- 2                                                         ";
1878        assert!((parse_endf_float(line, 0).unwrap() - 92_233.0).abs() < 1.0);
1879        assert!((parse_endf_float(line, 1).unwrap() - 0.01234).abs() < 1e-6);
1880    }
1881
1882    #[test]
1883    fn test_parse_endf_int() {
1884        let line = "          0          1          2          3          4          5            ";
1885        assert_eq!(parse_endf_int(line, 0).unwrap(), 0);
1886        assert_eq!(parse_endf_int(line, 1).unwrap(), 1);
1887        assert_eq!(parse_endf_int(line, 2).unwrap(), 2);
1888    }
1889
1890    /// Parse the vendored U-238 ENDF file (Reich-Moore, LRF=3).
1891    ///
1892    /// This test validates against the public-domain U-238 ENDF/B-VIII.0
1893    /// evaluation shipped at `examples/data/u238_ex027.endf` (the same
1894    /// file SAMMY distributes as `samexm_new/ex027_new/ex027.endf`). The
1895    /// first positive-energy resonance of U-238 is at 6.674 eV.
1896    ///
1897    /// Vendored under public-domain ENDF/B redistribution, so this gate
1898    /// runs unconditionally on CI — no `Skipping…` fall-through.
1899    #[test]
1900    fn test_parse_u238_sammy_endf() {
1901        // Crate-local copy so the test works when nereids-endf is built
1902        // standalone (outside the workspace, where `examples/data/` is
1903        // not packaged).  The original `examples/data/u238_ex027.endf`
1904        // is kept for end-user example code.
1905        let endf_path =
1906            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/u238_ex027.endf");
1907
1908        let endf_text = std::fs::read_to_string(&endf_path)
1909            .unwrap_or_else(|e| panic!("vendored U-238 fixture missing at {endf_path:?}: {e}"));
1910        let data = parse_endf_file2(&endf_text).unwrap();
1911
1912        // Basic structure checks.
1913        assert_eq!(data.za, 92238, "Should be U-238");
1914        assert!((data.awr - 236.006).abs() < 0.01, "AWR should be ~236");
1915        assert!(!data.ranges.is_empty(), "Should have at least one range");
1916
1917        let range = &data.ranges[0];
1918        assert!(range.resolved, "First range should be resolved");
1919        assert_eq!(
1920            range.formalism,
1921            ResonanceFormalism::ReichMoore,
1922            "U-238 ENDF uses Reich-Moore (LRF=3)"
1923        );
1924        assert!(
1925            (range.target_spin - 0.0).abs() < 1e-10,
1926            "U-238 target spin I=0"
1927        );
1928        assert!(
1929            (range.scattering_radius - 9.4285).abs() < 0.01,
1930            "Scattering radius ~9.4285 fm (ENDF 0.94285 × 10)"
1931        );
1932        assert_eq!(range.l_groups.len(), 2, "Should have L=0 and L=1 groups");
1933
1934        // Check first L-group (L=0).
1935        let l0 = &range.l_groups[0];
1936        assert_eq!(l0.l, 0, "First group should be L=0");
1937        assert!(
1938            l0.resonances.len() > 500,
1939            "L=0 should have hundreds of resonances"
1940        );
1941
1942        // Find the famous 6.674 eV resonance of U-238.
1943        let first_positive = l0
1944            .resonances
1945            .iter()
1946            .find(|r| r.energy > 0.0)
1947            .expect("Should have positive-energy resonances");
1948        assert!(
1949            (first_positive.energy - 6.674).abs() < 0.01,
1950            "First positive resonance should be at 6.674 eV, got {}",
1951            first_positive.energy
1952        );
1953        assert!(
1954            (first_positive.j - 0.5).abs() < 1e-10,
1955            "6.674 eV resonance has J=0.5"
1956        );
1957
1958        // The 6.674 eV resonance neutron width: ~1.493e-3 eV
1959        assert!(
1960            (first_positive.gn - 1.493e-3).abs() < 1e-5,
1961            "Neutron width should be ~1.493e-3 eV, got {}",
1962            first_positive.gn
1963        );
1964        // Gamma width: ~2.3e-2 eV
1965        assert!(
1966            (first_positive.gg - 2.3e-2).abs() < 1e-3,
1967            "Gamma width should be ~2.3e-2 eV, got {}",
1968            first_positive.gg
1969        );
1970
1971        // The aggregate accessor must cover at least the resolved range's
1972        // resonances (it sums over every range, including any unresolved
1973        // range the file carries).
1974        let total = data.total_resonance_count();
1975        let resolved_sum: usize = range.l_groups.iter().map(|g| g.resonances.len()).sum();
1976        assert!(
1977            total >= resolved_sum && resolved_sum > 500,
1978            "total_resonance_count ({total}) must cover the resolved range's \
1979             {resolved_sum} resonances"
1980        );
1981    }
1982
1983    /// Pin the Ta-181 ENDF/B-VIII.0 resonance count at 76 (genuinely-sparse RRR).
1984    ///
1985    /// Ta-181 (MAT 7328) in ENDF/B-VIII.0 has NER=2 ranges:
1986    ///   range 0: LRU=1 resolved, LRF=2 (MLBW), one L-group, 76 discrete
1987    ///            resonances, resolved region only to 330 eV;
1988    ///   range 1: LRU=2 unresolved (URR), 0 discrete resonances.
1989    /// So `total_resonance_count()` == 76 is **faithful**, not a dropped range —
1990    /// the parser reads every NER range and errors on unconsumed MF2/MT151 data.
1991    /// ENDF/B-VIII.1 later extended the resolved region (RRR to 2554 eV, 565
1992    /// resonances); a low VIII.0 count reflects that evaluation's resolved-region
1993    /// extent, not a parser bug. This test is a regression guard for that fact.
1994    ///
1995    /// Vendored under public-domain ENDF/B redistribution (73-Ta-181 LLNL EVAL,
1996    /// same evaluation NNDC/IAEA distribute) at the workspace-root
1997    /// `tests/data/endf/Ta-181.endf` (Hf-177 precedent). Inside the full NEREIDS
1998    /// workspace — where CI runs — the fixture is always present and the gate
1999    /// asserts fully; a standalone crate build (no workspace fixtures) skips.
2000    #[test]
2001    fn test_parse_ta181_endf8_0_resonance_count() {
2002        let endf_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2003            .parent()
2004            .unwrap()
2005            .parent()
2006            .unwrap()
2007            .join("tests/data/endf/Ta-181.endf");
2008        // The fixture is committed to the repo, so a missing file is a
2009        // packaging/path regression that MUST fail CI — not a skip that would
2010        // silently disable this resonance-count guard.
2011        let endf_text = std::fs::read_to_string(&endf_path).unwrap_or_else(|e| {
2012            panic!(
2013                "vendored Ta-181 regression fixture must be present at \
2014                 {endf_path:?} (committed test data): {e}"
2015            )
2016        });
2017        let data = parse_endf_file2(&endf_text).unwrap();
2018
2019        assert_eq!(data.za, 73181, "Should be Ta-181");
2020        assert!(
2021            (data.awr - 179.3936).abs() < 0.01,
2022            "AWR should be ~179.3936, got {}",
2023            data.awr
2024        );
2025
2026        // Faithful count: VIII.0's RRR is genuinely sparse (76), not a drop.
2027        assert_eq!(
2028            data.total_resonance_count(),
2029            76,
2030            "Ta-181 VIII.0 has 76 discrete resonances (sparse RRR to 330 eV)"
2031        );
2032        assert_eq!(
2033            data.ranges.len(),
2034            2,
2035            "Ta-181 VIII.0 has NER=2 ranges (resolved MLBW + unresolved URR)"
2036        );
2037
2038        // Range 0: resolved MLBW (LRF=2), all 76 resonances live here.
2039        let resolved = &data.ranges[0];
2040        assert!(resolved.resolved, "Range 0 must be resolved (LRU=1)");
2041        assert_eq!(
2042            resolved.formalism,
2043            ResonanceFormalism::MLBW,
2044            "Ta-181 VIII.0 resolved range uses MLBW (LRF=2)"
2045        );
2046        assert_eq!(
2047            resolved.resonance_count(),
2048            76,
2049            "All 76 resonances belong to the resolved MLBW range"
2050        );
2051
2052        // Range 1: unresolved (LRU=2, URR), zero discrete resonances.
2053        let unresolved = &data.ranges[1];
2054        assert!(!unresolved.resolved, "Range 1 must be unresolved (LRU=2)");
2055        assert_eq!(
2056            unresolved.formalism,
2057            ResonanceFormalism::Unresolved,
2058            "Ta-181 VIII.0 second range is the URR (LRU=2)"
2059        );
2060        assert_eq!(
2061            unresolved.resonance_count(),
2062            0,
2063            "URR range carries no discrete resonances"
2064        );
2065
2066        // Mixed evaluation: the resolved MLBW range keeps the file loadable,
2067        // and the skipped URR range must be flagged as unevaluated so callers
2068        // can warn that its span contributes zero cross-section.
2069        assert!(
2070            data.has_unevaluated_ranges(),
2071            "Ta-181's URR range must be flagged as unevaluated"
2072        );
2073        assert_eq!(
2074            data.unevaluated_ranges().len(),
2075            1,
2076            "exactly the URR range is unevaluated"
2077        );
2078    }
2079
2080    /// Assert that a fixture whose every range is a parse-and-skip placeholder
2081    /// is rejected with the no-evaluable-ranges error.
2082    ///
2083    /// This still pins cursor advancement: a misaligned skip surfaces as a
2084    /// *different* error (multi-material guard, unexpected EOF, or a
2085    /// structural-guard message), not this one — so reaching this specific
2086    /// message proves the skip consumed exactly the range body.
2087    #[track_caller]
2088    fn assert_rejected_no_evaluable_ranges(endf: &str) {
2089        let err = parse_endf_file2(endf).unwrap_err();
2090        match err {
2091            EndfParseError::UnsupportedFormat(ref msg) => {
2092                assert!(
2093                    msg.contains("No evaluable resonance ranges"),
2094                    "expected no-evaluable-ranges rejection, got: {msg}"
2095                );
2096            }
2097            other => panic!("expected UnsupportedFormat, got: {other:?}"),
2098        }
2099    }
2100
2101    /// Parse a minimal hand-crafted ENDF snippet with NRO=1 (energy-dependent
2102    /// scattering radius).
2103    ///
2104    /// The fixture encodes:
2105    /// - LRF=3 (Reich-Moore), NRO=1
2106    /// - AP TAB1: 2 points — ENDF values 8.0 and 10.0 (10⁻¹² cm),
2107    ///   which become 80.0 fm and 100.0 fm after ×10 conversion
2108    /// - One L-group (L=0) with one resonance at 6.674 eV
2109    ///
2110    /// Verifies:
2111    /// - ap_table is Some after parsing
2112    /// - ap_table.evaluate(1.0) ≈ 80.0 fm (8.0 × ENDF_RADIUS_TO_FM)
2113    /// - ap_table.evaluate(500.5) ≈ 90.0 fm (midpoint, lin-lin)
2114    /// - ap_table.evaluate(1000.0) ≈ 100.0 fm
2115    /// - scattering_radius_at() delegates to the table
2116    #[test]
2117    fn test_parse_nro1_tab1() {
2118        // Each ENDF line is exactly 80 chars: 66 data chars + 14 MAT/MF/MT/SEQ.
2119        // Cols 67-70: MAT=9237, Cols 71-72: MF=2, Cols 73-75: MT=151, Cols 76-80: seq
2120        //
2121        // Line layout (11 chars per field × 6 fields = 66 chars, then 14 control chars):
2122        //   HEAD:  ZA=92238  AWR=236.006  0  0  NIS=1  0
2123        //   CONT:  ZAI=92238 ABN=1.0      0  LFW=0 NER=1  0
2124        //   CONT:  EL=1e-5   EH=1e4    LRU=1  LRF=3  NRO=1  NAPS=0
2125        //   TAB1 CONT: 0  0  0  0  NR=1  NP=2
2126        //   TAB1 interp: NBT=2, INT=2  (plus 4 padding zeros)
2127        //   TAB1 data:   (1.0, 8.0), (1000.0, 10.0)
2128        //   RM CONT:  SPI=0.0  AP=9.0  0  0  NLS=1  0
2129        //   L CONT:  AWRI=236.006  0  L=0  0  6*NRS=6  NRS=1
2130        //   Resonance: ER=6.674  AJ=0.5  GN=1.493e-3  GG=23e-3  GFA=0  GFB=0
2131        //   SEND: all zeros
2132        // Each ENDF line: 66 data chars + 4-char MAT(9237) + 2-char MF(" 2")
2133        //   + 3-char MT("151") + 5-char SEQ = 80 chars total.
2134        let endf = include_str!("../../../tests/data/synthetic/lrf3_nro1_tab1.endf");
2135
2136        let data = parse_endf_file2(endf).expect("NRO=1 fixture must parse cleanly");
2137        assert_eq!(data.ranges.len(), 1, "one energy range");
2138
2139        let range = &data.ranges[0];
2140        assert_eq!(
2141            range.formalism,
2142            ResonanceFormalism::ReichMoore,
2143            "must be LRF=3"
2144        );
2145
2146        let table = range
2147            .ap_table
2148            .as_ref()
2149            .expect("NRO=1 range must have ap_table");
2150        assert_eq!(table.points.len(), 2, "TAB1 must have 2 points");
2151
2152        // Exact boundary values (ENDF 8.0 × 10 = 80.0 fm).
2153        assert!(
2154            (table.evaluate(1.0) - 80.0).abs() < 1e-10,
2155            "AP(1 eV) = 80.0 fm"
2156        );
2157        assert!(
2158            (table.evaluate(1000.0) - 100.0).abs() < 1e-10,
2159            "AP(1000 eV) = 100.0 fm"
2160        );
2161        // Lin-lin midpoint: AP(500.5 eV) ≈ 90.0 fm.
2162        let mid = table.evaluate(500.5);
2163        assert!((mid - 90.0).abs() < 0.1, "AP midpoint ≈ 90.0 fm, got {mid}");
2164
2165        // scattering_radius_at delegates to the table.
2166        assert!(
2167            (range.scattering_radius_at(1.0) - 80.0).abs() < 1e-10,
2168            "scattering_radius_at(1 eV) = 80.0"
2169        );
2170        assert!(
2171            (range.scattering_radius_at(1000.0) - 100.0).abs() < 1e-10,
2172            "scattering_radius_at(1000 eV) = 100.0"
2173        );
2174
2175        // Resonance is still parsed correctly.
2176        assert_eq!(range.l_groups.len(), 1, "one L-group");
2177        let res = &range.l_groups[0].resonances[0];
2178        assert!((res.energy - 6.674).abs() < 1e-6);
2179    }
2180
2181    /// LFW=1 with LRF=2 (tabulated widths, U-233-style record).
2182    ///
2183    /// SAMMY test tr149 (`t149a.endf`, MAT=9222, ZA=92233) has two ranges:
2184    ///   - Range 0: LRU=1 (resolved, Reich-Moore / LRF=3)
2185    ///   - Range 1: LRU=2, LRF=2, **LFW=1** (energy-dependent fission widths)
2186    ///
2187    /// ENDF-6 §2.2.2.2: for LFW=1/LRF=2 the per-(L,J) LIST layout is
2188    /// **identical to LFW=0/LRF=2** (the fission widths are already
2189    /// per-energy-point in the LIST tail), so the parser dispatches to the
2190    /// shared `skip_urr_range` path: the URR body is parsed-and-skipped
2191    /// (structural guards applied, values discarded) and the range is
2192    /// stored as a non-evaluable placeholder.
2193    ///
2194    /// sibling checkout; on CI (and on any clean clone) the file was
2195    /// absent and the test silently reported `ok` after a `Skipping…`
2196    /// print. Vendoring the full tr149 ENDF would be heavy; instead we
2197    /// synthesise a minimal but record-shape-faithful LFW=1/LRF=2
2198    /// fixture so the assertion runs unconditionally.
2199    #[test]
2200    fn test_parse_u233_lfw1_lrf2_urr_parsed() {
2201        // Minimal MF=2/MT=151 with two ranges, mirroring tr149 layout:
2202        //   Range 0: LRU=1, LRF=3 (Reich-Moore) — one trivial resonance.
2203        //   Range 1: LRU=2, LRF=2, LFW=1       — NLS=1, NJS=1, NE=2.
2204        // LFW=1 is flagged on the isotope CONT (L2 field).
2205        const ENDF: &str = include_str!("../../../tests/data/synthetic/u233_lfw1_lrf2_urr.endf");
2206
2207        let data = parse_endf_file2(ENDF)
2208            .expect("U-233 LFW=1/LRF=2 fixture must parse (record layout = LFW=0/LRF=2)");
2209
2210        // Both ranges must round-trip: resolved + URR.
2211        assert_eq!(data.ranges.len(), 2, "must have 2 ranges (resolved + URR)");
2212
2213        let resolved_count = data.ranges.iter().filter(|r| r.resolved).count();
2214        assert_eq!(resolved_count, 1, "exactly one resolved range");
2215
2216        // The URR range is parsed-and-skipped: it must appear as a
2217        // non-resolved Unresolved range. LFW=1/LRF=2 shares the LFW=0/LRF=2
2218        // record layout, so a mis-sized skip would trip the multi-material
2219        // guard or drop the resolved range — the counts above pin alignment.
2220        let urr_count = data
2221            .ranges
2222            .iter()
2223            .filter(|r| r.formalism == ResonanceFormalism::Unresolved)
2224            .count();
2225        assert_eq!(
2226            urr_count, 1,
2227            "LFW=1/LRF=2 URR range must be present (skipped)"
2228        );
2229        let urr_range = data
2230            .ranges
2231            .iter()
2232            .find(|r| r.formalism == ResonanceFormalism::Unresolved)
2233            .expect("URR range must exist");
2234        assert!(!urr_range.resolved, "URR range must not be resolved");
2235
2236        // Mixed evaluation: the resolved range keeps the file loadable, and
2237        // the skipped URR range must be flagged as unevaluated.
2238        assert!(data.has_unevaluated_ranges());
2239        assert_eq!(data.unevaluated_ranges().len(), 1);
2240    }
2241
2242    /// Hand-crafted LRF=1 URR roundtrip test.
2243    ///
2244    /// Verifies that a minimal synthetic ENDF snippet with LRU=2, LRF=1 is
2245    /// parsed correctly: one L-group (L=0), two J-groups with known D, AJ,
2246    /// AMUN, GNO, GG, GF values.
2247    #[test]
2248    fn test_parse_lrf1_urr_roundtrip() {
2249        // Minimal ENDF MF=2/MT=151 with one resolved range followed by one
2250        // LRU=2 LRF=1 unresolved range.
2251        //
2252        // Resolved range: a simple RM LRF=3 with one resonance (gives the
2253        // parser something valid to consume before the URR section).
2254        //
2255        // URR range: LRU=2, LRF=1, NLS=1 (L=0), NJS=2 J-groups.
2256        //   J=2.0: D=0.5 eV, AMUN=1, GNO=3e-4 eV, GG=3.5e-2 eV, GF=0
2257        //   J=3.0: D=0.4 eV, AMUN=1, GNO=2e-4 eV, GG=3.0e-2 eV, GF=1e-3 eV
2258        //
2259        // Each ENDF line: 66 data chars + MAT(4) MF(2) MT(3) SEQ(5) = 80 chars.
2260        const ENDF: &str = include_str!("../../../tests/data/synthetic/lrf1_urr_roundtrip.endf");
2261
2262        let data = parse_endf_file2(ENDF).expect("LRF=1 URR fixture must parse cleanly");
2263
2264        // Should have 2 ranges: one resolved + one URR.
2265        assert_eq!(data.ranges.len(), 2, "must have 2 ranges");
2266
2267        let urr_range = &data.ranges[1];
2268        assert!(!urr_range.resolved, "URR range must not be resolved");
2269        assert_eq!(
2270            urr_range.formalism,
2271            ResonanceFormalism::Unresolved,
2272            "formalism must be Unresolved"
2273        );
2274
2275        // The LRF=1 URR body is parsed-and-skipped; the range is present as
2276        // Unresolved (asserted above). ranges.len()==2 confirms the skip
2277        // consumed exactly the URR body and left the line stream aligned.
2278
2279        // Mixed evaluation: the resolved range keeps the file loadable, and
2280        // the skipped URR range must be flagged as unevaluated.
2281        assert!(data.has_unevaluated_ranges());
2282        assert_eq!(data.unevaluated_ranges().len(), 1);
2283    }
2284
2285    /// An LRF=2 URR range with INT=3 (log-x/lin-y) passes the per-J INT guard
2286    /// (§0.5: INT∈1..=5 — a stricter guard would spuriously reject valid
2287    /// evaluations), then the file is rejected because the URR range is its
2288    /// only range (no evaluable content).
2289    #[test]
2290    fn test_parse_lrf2_urr_int3_rejected_no_evaluable_ranges() {
2291        // Minimal ENDF MF=2/MT=151 with one LRU=2/LRF=2 range:
2292        // NLS=1 (L=0), NJS=1, NE=2 energy points, INT=3 (log-x/lin-y).
2293        // LIST layout: row 0 = [0, 0, 0, AMUN, 0, AMUF]; rows 1..=NE = (E,
2294        // D, GX, GN, GG, GF). Total = 6*(NE+1) = 18 floats = 3 lines.
2295        // Reaching the no-evaluable-ranges error (not an INT rejection or a
2296        // misalignment error) proves the INT=3 guard accepted the record and
2297        // the skip consumed exactly the URR body.
2298        const ENDF: &str =
2299            include_str!("../../../tests/data/synthetic/lrf2_urr_int3_roundtrip.endf");
2300        assert_rejected_no_evaluable_ranges(ENDF);
2301    }
2302
2303    /// LRF=2 URR with INT=0 is rejected as a hard error.
2304    ///
2305    /// ENDF-6 §0.5 defines INT codes 1..=5 only. INT=0 is malformed,
2306    /// not merely unsupported, so the parser surfaces it as
2307    /// `UnsupportedFormat("INT=0 out of spec (expected 1..=5)")` rather
2308    /// than panicking or silently defaulting to lin-lin.
2309    #[test]
2310    fn test_parse_lrf2_urr_int0_rejected() {
2311        // Same skeleton as INT=3 test, but with INT=0 in the J CONT.
2312        const ENDF: &str =
2313            include_str!("../../../tests/data/synthetic/lrf2_urr_int0_rejected.endf");
2314
2315        let err = parse_endf_file2(ENDF).unwrap_err();
2316        // Match on the error variant structurally so the test only
2317        // depends on the parser surfacing `UnsupportedFormat`, not on
2318        // the exact `Display` wording of `EndfParseError`.  Then check
2319        // the payload string for the diagnostic substrings.
2320        match err {
2321            EndfParseError::UnsupportedFormat(ref msg) => {
2322                assert!(
2323                    msg.contains("INT=0") && msg.contains("out of spec"),
2324                    "expected INT-out-of-spec rejection in UnsupportedFormat payload, \
2325                     got: {msg}"
2326                );
2327            }
2328            other => panic!("expected EndfParseError::UnsupportedFormat for INT=0, got: {other:?}"),
2329        }
2330    }
2331
2332    // -----------------------------------------------------------------------
2333    // robustness tests for malformed input validation
2334    // -----------------------------------------------------------------------
2335
2336    /// `checked_count` rejects negative values.
2337    #[test]
2338    fn test_checked_count_negative() {
2339        let err = checked_count(-1, "NLS").unwrap_err();
2340        assert!(
2341            err.to_string().contains("Negative"),
2342            "expected negative error, got: {err}"
2343        );
2344    }
2345
2346    /// `checked_count` rejects values above `MAX_ENDF_COUNT` to prevent
2347    /// allocation bombs from malformed files.
2348    #[test]
2349    fn test_checked_count_upper_bound() {
2350        // Just above the limit.
2351        let err = checked_count(MAX_ENDF_COUNT + 1, "NRS").unwrap_err();
2352        assert!(
2353            err.to_string().contains("too large"),
2354            "expected upper-bound error, got: {err}"
2355        );
2356
2357        // At the limit: should succeed.
2358        assert_eq!(checked_count(MAX_ENDF_COUNT, "NRS").unwrap(), 1_000_000);
2359
2360        // i32::MAX: should be rejected.
2361        let err = checked_count(i32::MAX, "NPL").unwrap_err();
2362        assert!(
2363            err.to_string().contains("too large"),
2364            "expected upper-bound error for i32::MAX, got: {err}"
2365        );
2366    }
2367
2368    /// `parse_endf_int` rejects non-integral float values rather than
2369    /// silently truncating.
2370    ///
2371    /// Without the strict check, an INT-field value stored as
2372    /// `"1.900000+0"` would be cast as `1.9_f64 as i32 == 1`, masking
2373    /// a malformed evaluation.  After the strict check, the parser
2374    /// surfaces `InvalidNumber` immediately.
2375    ///
2376    /// The two field layouts:
2377    ///   • field 0: integral float "1.000000+0" → returns Ok(1)
2378    ///   • field 1: non-integral  "1.900000+0" → returns Err(InvalidNumber)
2379    /// must both round-trip the standard ENDF integer-as-float encoding.
2380    #[test]
2381    fn test_parse_endf_int_rejects_non_integral_float() {
2382        // 11-char fields, padded with leading space to match the ENDF column
2383        // width.  Field 0 is integral; field 1 is non-integral.
2384        const LINE: &str = " 1.000000+0 1.900000+0";
2385
2386        // Integral float must parse cleanly.
2387        let ok = parse_endf_int(LINE, 0).expect("integral float must parse");
2388        assert_eq!(ok, 1);
2389
2390        // Non-integral float must be rejected, not truncated to 1.
2391        let err = parse_endf_int(LINE, 1).expect_err("non-integral must be rejected");
2392        match err {
2393            EndfParseError::InvalidNumber(ref msg) => {
2394                assert!(
2395                    msg.contains("Non-integral"),
2396                    "expected Non-integral diagnostic, got: {msg}"
2397                );
2398            }
2399            other => panic!("expected InvalidNumber, got: {other:?}"),
2400        }
2401    }
2402
2403    /// Negative L-value in a Breit-Wigner range is rejected.
2404    ///
2405    /// Constructs a minimal SLBW fixture with L=-1 in the L-group CONT record.
2406    /// Without the validation, `l_cont.l1 as u32` would wrap to `u32::MAX`.
2407    #[test]
2408    fn test_bw_negative_l_rejected() {
2409        // Minimal SLBW fixture: HEAD + isotope CONT + range CONT + SPI/AP CONT +
2410        // L-group CONT with L=-1 (field 3 = -1).
2411        const ENDF: &str =
2412            include_str!("../../../tests/data/synthetic/lrf1_bw_negative_l_rejected.endf");
2413
2414        let err = parse_endf_file2(ENDF).unwrap_err();
2415        assert!(
2416            err.to_string().contains("negative L"),
2417            "expected negative L error, got: {err}"
2418        );
2419    }
2420
2421    /// Negative L-value in a Reich-Moore range is rejected.
2422    #[test]
2423    fn test_rm_negative_l_rejected() {
2424        const ENDF: &str =
2425            include_str!("../../../tests/data/synthetic/lrf3_rm_negative_l_rejected.endf");
2426
2427        let err = parse_endf_file2(ENDF).unwrap_err();
2428        assert!(
2429            err.to_string().contains("negative L"),
2430            "expected negative L error, got: {err}"
2431        );
2432    }
2433
2434    /// LRU=0 range with non-zero NLS is rejected.
2435    ///
2436    /// ENDF-6 §2.2 says the SPI/AP CONT after an LRU=0 range must have
2437    /// NLS=0 (no L-groups for scattering-radius-only ranges).
2438    #[test]
2439    fn test_lru0_nonzero_nls_rejected() {
2440        const ENDF: &str =
2441            include_str!("../../../tests/data/synthetic/lru0_nonzero_nls_rejected.endf");
2442
2443        let err = parse_endf_file2(ENDF).unwrap_err();
2444        assert!(
2445            err.to_string().contains("NLS=3"),
2446            "expected LRU=0 NLS validation error, got: {err}"
2447        );
2448    }
2449
2450    /// A resolved (LRU=1) MLBW range with NLS=0 is rejected.
2451    ///
2452    /// ENDF-6 §2.2.1.1 requires NLS >= 1 in a resolved range (NLS=0 is
2453    /// reserved for LRU=0 scattering-radius-only stanzas). Without the guard
2454    /// this file loads as "evaluable" with zero resonances — zero
2455    /// cross-section everywhere and no warning.
2456    #[test]
2457    fn test_mlbw_nls0_rejected() {
2458        const ENDF: &str = include_str!("../../../tests/data/synthetic/mlbw_nls0_rejected.endf");
2459
2460        let err = parse_endf_file2(ENDF).unwrap_err();
2461        assert!(
2462            err.to_string().contains("NLS=0"),
2463            "expected resolved NLS=0 rejection, got: {err}"
2464        );
2465    }
2466
2467    /// A file whose ONLY range is resolved with NLS=1 but NRS=0 in every
2468    /// L-group is rejected by the no-evaluable-content guard, naming the
2469    /// inert span. (The range itself is accepted as a warn-and-skip
2470    /// placeholder — SAMMY/OpenScale accept NRS=0 L-groups — so the
2471    /// rejection comes from the file-level guard, not a per-range error.)
2472    #[test]
2473    fn test_mlbw_nrs0_everywhere_rejected() {
2474        const ENDF: &str =
2475            include_str!("../../../tests/data/synthetic/mlbw_nrs0_everywhere_rejected.endf");
2476
2477        let err = parse_endf_file2(ENDF).unwrap_err();
2478        let msg = err.to_string();
2479        assert!(
2480            msg.contains("No evaluable resonance ranges"),
2481            "expected no-evaluable-content rejection, got: {msg}"
2482        );
2483        assert!(
2484            msg.contains("resolved range with no resonances"),
2485            "rejection must name the inert resolved span, got: {msg}"
2486        );
2487    }
2488
2489    /// A mixed file [populated resolved range][all-empty resolved range]
2490    /// loads: the populated range stays evaluable, the inert range becomes a
2491    /// warn-and-skip placeholder — symmetric with LRF=7/LRU=2/LRU=0 handling,
2492    /// so the good range is not lost.
2493    #[test]
2494    fn test_mlbw_nrs0_plus_resolved_mixed_loads_and_flags() {
2495        const ENDF: &str =
2496            include_str!("../../../tests/data/synthetic/mlbw_nrs0_plus_resolved_mixed.endf");
2497
2498        let data = parse_endf_file2(ENDF).expect("mixed populated + inert file must load");
2499        assert_eq!(data.ranges.len(), 2);
2500        assert!(data.has_evaluable_range());
2501        assert!(data.has_unevaluated_ranges());
2502        assert_eq!(data.total_resonance_count(), 1);
2503        let inert = data
2504            .ranges
2505            .iter()
2506            .find(|r| !r.is_evaluable())
2507            .expect("the inert range must be present");
2508        assert!(
2509            inert
2510                .skip_description()
2511                .contains("LRF=2 (MLBW) resolved range with no resonances"),
2512            "skip_description must name the inert shape, got: {}",
2513            inert.skip_description()
2514        );
2515    }
2516
2517    /// An MLBW range with a competitive width (LRX=1) loads but is not
2518    /// evaluated; the range before it still is.
2519    #[test]
2520    fn test_mlbw_lrx1_range_is_parsed_but_not_evaluated() {
2521        const ENDF: &str =
2522            include_str!("../../../tests/data/synthetic/mlbw_lrx1_plus_resolved_mixed.endf");
2523
2524        let data = parse_endf_file2(ENDF).expect("the LRX=0 range keeps the file loadable");
2525        assert_eq!(data.ranges.len(), 2);
2526        let competitive = &data.ranges[1].l_groups[0];
2527        assert_eq!((competitive.lrx, competitive.qx), (1, -4.49e4));
2528        assert!(data.ranges[0].is_evaluable());
2529        assert_eq!(data.unevaluated_ranges().len(), 1);
2530        assert!(
2531            data.ranges[1]
2532                .skip_description()
2533                .contains("LRF=2 (MLBW) resolved range with a competitive width (LRX=1)"),
2534            "got: {}",
2535            data.ranges[1].skip_description()
2536        );
2537    }
2538
2539    /// A resolved (LRU=1) Reich-Moore range with NLS=0 is rejected.
2540    #[test]
2541    fn test_rm_nls0_rejected() {
2542        const ENDF: &str = include_str!("../../../tests/data/synthetic/lrf3_rm_nls0_rejected.endf");
2543
2544        let err = parse_endf_file2(ENDF).unwrap_err();
2545        assert!(
2546            err.to_string().contains("NLS=0"),
2547            "expected resolved NLS=0 rejection, got: {err}"
2548        );
2549    }
2550
2551    /// LFW=1/LRF=1 (energy-dependent fission widths) URR is skipped with the
2552    /// line stream left aligned.
2553    ///
2554    /// ENDF-6 §2.2.2.1 Case B: a shared NE-point energy grid is followed,
2555    /// for each (L, J), by a full LIST record — a control line
2556    /// `[0.0, 0.0, L, MUF, NE+6, 0]` and then a body
2557    /// `[D, AJ, AMUN, GNO, GG, 0] + GF(1..NE)`.  The per-J control line MUST
2558    /// be consumed before the body; otherwise the line stream misaligns by
2559    /// one record per J-group and the file fails to parse (the stray lines
2560    /// trip the multi-material guard).
2561    ///
2562    /// This fixture is standards-compliant (it includes the per-J control
2563    /// line); the assertion that it parses end-to-end is the regression guard
2564    /// for the Case-B skip's cursor advancement.
2565    ///
2566    /// The fixture has NE=2, NLS=1, NJS=1, MUF=1.
2567    #[test]
2568    fn test_lfw1_lrf1_urr_rejected_no_evaluable_ranges() {
2569        const ENDF: &str =
2570            include_str!("../../../tests/data/synthetic/lfw1_urr_gracefully_skipped.endf");
2571
2572        // The Case-B URR body is parsed-and-skipped; consuming the per-J
2573        // control line (N1=NE+6) keeps the stream aligned. The fixture's only
2574        // range is the URR, so the aligned parse then hits the
2575        // no-evaluable-ranges rejection — a misaligned skip would instead trip
2576        // the multi-material guard or EOF (see the fixture note above), so
2577        // reaching this specific error pins the cursor advancement.
2578        assert_rejected_no_evaluable_ranges(ENDF);
2579    }
2580
2581    /// A per-J LIST control whose `N1 != NE+6` is a malformed Case-B record.
2582    /// The parser must reject it — this covers the per-J N1 validation guard
2583    /// (the SCALE `list.getN1()-6 == ener.getNtot()` relation). The fixture is
2584    /// byte-identical to the valid one except the per-J control N1 (8 -> 7).
2585    #[test]
2586    fn test_lfw1_lrf1_urr_rejects_bad_perj_n1() {
2587        const ENDF: &str = include_str!("../../../tests/data/synthetic/lfw1_urr_bad_perj_n1.endf");
2588
2589        let err = parse_endf_file2(ENDF).unwrap_err();
2590        assert!(
2591            err.to_string().contains("N1=") && err.to_string().contains("NE+6"),
2592            "expected per-J N1 != NE+6 rejection, got: {err}"
2593        );
2594    }
2595
2596    /// An LRU=2 (URR) range with LRF ∉ {1, 2} is a malformed record and is
2597    /// rejected with an error naming the offending LRF.
2598    ///
2599    /// ENDF-6 §2.2.2 restricts the unresolved region to LRF=1 or LRF=2. The
2600    /// span silently; it now hard-errors like the sibling malformed-record
2601    /// guards.
2602    #[test]
2603    fn test_urr_lrf3_rejected() {
2604        const ENDF: &str = include_str!("../../../tests/data/synthetic/urr_lrf3_rejected.endf");
2605
2606        let err = parse_endf_file2(ENDF).unwrap_err();
2607        let msg = err.to_string();
2608        assert!(
2609            msg.contains("LRF=3") && msg.contains("URR"),
2610            "expected URR LRF=3 rejection naming the LRF, got: {msg}"
2611        );
2612    }
2613
2614    /// LRU=0 range with non-zero L1 in SPI/AP CONT is rejected.
2615    ///
2616    /// ENDF-6 §2.2: the SPI/AP CONT record for LRU=0 must be
2617    /// [SPI, AP, 0, 0, NLS=0, 0].  Non-zero L1 or L2 indicates a
2618    /// malformed or mis-identified record.
2619    #[test]
2620    fn test_lru0_nonzero_l1_rejected() {
2621        const ENDF: &str =
2622            include_str!("../../../tests/data/synthetic/lru0_nonzero_l1_rejected.endf");
2623
2624        let err = parse_endf_file2(ENDF).unwrap_err();
2625        assert!(
2626            err.to_string().contains("L1=5"),
2627            "expected LRU=0 L1 validation error, got: {err}"
2628        );
2629    }
2630
2631    /// A well-formed LRU=0-only evaluation (scattering radius, no resonance
2632    /// parameters) is rejected — NEREIDS cannot evaluate it and loading it
2633    /// would yield silent zero cross-sections — but the message NAMES the
2634    /// LRU=0 span rather than misreporting an empty file.
2635    ///
2636    /// ENDF-6 §2.1: LRU=0 gives a scattering radius but no resonances. The
2637    /// fixture is byte-identical to `lru0_nonzero_nls_rejected` except the
2638    /// SPI/AP CONT carries NLS=0 (well-formed), so parsing reaches the
2639    /// no-evaluable-content guard rather than the NLS validation guard.
2640    #[test]
2641    fn test_lru0_only_rejected_names_lru0_span() {
2642        const ENDF: &str =
2643            include_str!("../../../tests/data/synthetic/lru0_only_rejected_no_evaluable.endf");
2644
2645        let err = parse_endf_file2(ENDF).unwrap_err();
2646        let msg = err.to_string();
2647        assert!(
2648            msg.contains("No evaluable resonance ranges"),
2649            "expected no-evaluable-ranges rejection, got: {msg}"
2650        );
2651        assert!(
2652            msg.contains("LRU=0 (scattering-radius-only, no resonance parameters)"),
2653            "rejection must name the LRU=0 span, got: {msg}"
2654        );
2655    }
2656
2657    /// A mixed evaluation carrying an LRU=0 range plus a resolved range still
2658    /// loads: the resolved range keeps the file evaluable, and the LRU=0 span
2659    /// is surfaced as a non-evaluable parse-and-skip placeholder (same handling
2660    /// as LRF=7 / LRU=2).
2661    #[test]
2662    fn test_lru0_plus_resolved_mixed_loads_and_flags_lru0() {
2663        const ENDF: &str =
2664            include_str!("../../../tests/data/synthetic/lru0_plus_resolved_mixed.endf");
2665
2666        let data = parse_endf_file2(ENDF).expect("mixed LRU=0 + resolved file must load");
2667        assert_eq!(data.ranges.len(), 2, "LRU=0 range + resolved range");
2668        assert!(
2669            data.has_evaluable_range(),
2670            "resolved range keeps it evaluable"
2671        );
2672        assert!(data.has_unevaluated_ranges(), "LRU=0 range is unevaluated");
2673
2674        let lru0 = data
2675            .ranges
2676            .iter()
2677            .find(|r| r.formalism == ResonanceFormalism::ScatteringRadiusOnly)
2678            .expect("the LRU=0 range must be present");
2679        assert!(!lru0.resolved, "LRU=0 range must not be resolved");
2680        assert!(
2681            lru0.skip_description()
2682                .contains("LRU=0 (scattering-radius-only, no resonance parameters)"),
2683            "LRU=0 skip_description must name the span, got: {}",
2684            lru0.skip_description()
2685        );
2686
2687        // The fixture gives the placeholder DIFFERENT SPI/AP (1.5 / 5.0 fm)
2688        // than the resolved range (2.5 / 9.6931 fm), so consumers that must
2689        // prefer the evaluable range are distinguishable from ones reading
2690        // the placeholder.
2691        assert_eq!(lru0.target_spin, 1.5);
2692        assert_eq!(lru0.scattering_radius, 5.0);
2693        let resolved = data
2694            .ranges
2695            .iter()
2696            .find(|r| r.is_evaluable())
2697            .expect("the resolved range must be evaluable");
2698        assert_eq!(resolved.target_spin, 2.5);
2699        assert!((resolved.scattering_radius - 9.6931).abs() < 1e-9);
2700    }
2701
2702    /// N1 != 6*NRS in a BW range CONT is rejected.
2703    #[test]
2704    fn test_bw_n1_nrs_mismatch_rejected() {
2705        const ENDF: &str =
2706            include_str!("../../../tests/data/synthetic/lrf1_bw_n1_nrs_mismatch_rejected.endf");
2707
2708        let err = parse_endf_file2(ENDF).unwrap_err();
2709        assert!(
2710            err.to_string().contains("N1=7"),
2711            "expected N1/NRS mismatch error, got: {err}"
2712        );
2713    }
2714
2715    /// Multi-MAT detection: unconsumed MF=2/MT=151 lines after the first
2716    /// material are rejected.
2717    #[test]
2718    fn test_multi_mat_detection() {
2719        // A valid single-range SLBW file with an extra trailing data line
2720        // that still carries MF=2/MT=151 tags.
2721        const ENDF: &str = include_str!("../../../tests/data/synthetic/multi_mat_detection.endf");
2722
2723        let err = parse_endf_file2(ENDF).unwrap_err();
2724        assert!(
2725            err.to_string().contains("Multiple materials"),
2726            "expected multi-MAT error, got: {err}"
2727        );
2728    }
2729
2730    /// MF=2 NIS>1 multi-isotope materials are rejected.
2731    ///
2732    /// ENDF-6 §2.1 allows a single material to carry several isotopes, each
2733    /// with its own ZAI/ABN/NER subsection. NEREIDS's `ResonanceData` cannot
2734    /// represent that hierarchy without losing per-isotope abundance weights;
2735    /// rather than silently flatten the ranges into one isotope, the parser
2736    /// returns `UnsupportedFormat` so a downstream consumer cannot be tricked
2737    /// into computing an abundance-blind cross section.
2738    ///
2739    /// The synthetic fixture is a minimal two-isotope SLBW material: Cu-63
2740    /// (ZAI=29063, ABN=0.6917) and Cu-65 (ZAI=29065, ABN=0.3083), each with
2741    /// a single L=0 resonance. The parser should reject the file as soon as
2742    /// it reads the HEAD record (NIS=2 > 1) and never advance into either
2743    /// isotope subsection.
2744    #[test]
2745    fn test_parse_endf_rejects_nis_gt_1() {
2746        // Minimal NIS=2 fixture. Both isotope subsections use LRF=1 SLBW so
2747        // that, if the NIS guard were ever removed, the parser would have a
2748        // valid stream to walk and the assertion below would still pin the
2749        // guard. The HEAD's ZA is set to the natural-element identifier
2750        // ZA=29000 (nat-Cu); for NIS=1 callers that would be rejected by
2751        // `isotope_from_za` with an "A=0" error, but the NIS=2 check runs
2752        // first and returns the expected UnsupportedFormat.
2753        const ENDF: &str = include_str!("../../../tests/data/synthetic/nis_gt_1_rejected.endf");
2754
2755        let err = parse_endf_file2(ENDF).unwrap_err();
2756        match &err {
2757            EndfParseError::UnsupportedFormat(msg) => {
2758                assert!(
2759                    msg.contains("NIS=2"),
2760                    "expected NIS=2 in error message, got: {msg}"
2761                );
2762                assert!(
2763                    msg.contains("multi-isotope"),
2764                    "expected 'multi-isotope' in error message, got: {msg}"
2765                );
2766            }
2767            other => panic!("expected UnsupportedFormat for NIS>1, got {other:?}"),
2768        }
2769    }
2770
2771    /// MF=2 NIS=0 (no isotopes declared) is rejected up-front.
2772    ///
2773    /// ENDF-6 §2.1 requires NIS >= 1 for a valid resonance evaluation.
2774    /// Without the explicit reject, NIS=0 would fall through the per-isotope
2775    /// loop (zero iterations), leave the resonance section empty, and trip a
2776    /// confusing downstream "unconsumed data lines" / empty-range failure
2777    /// far from the actual root cause. The reject mirrors the NIS>1 guard
2778    /// pattern so both invalid extremes return a clear UnsupportedFormat.
2779    #[test]
2780    fn test_parse_endf_rejects_nis_zero() {
2781        // Minimal NIS=0 fixture: just the HEAD line with NIS=0. The HEAD's
2782        // ZA=74184 (W-184) is a valid identifier, so any error must come
2783        // from the NIS=0 guard, not from `isotope_from_za`.
2784        // HEAD: ZA=74184, AWR=182, NIS=0
2785        const ENDF: &str = include_str!("../../../tests/data/synthetic/nis_zero_rejected.endf");
2786
2787        let err = parse_endf_file2(ENDF).unwrap_err();
2788        match &err {
2789            EndfParseError::UnsupportedFormat(msg) => {
2790                assert!(
2791                    msg.contains("NIS=0"),
2792                    "expected NIS=0 in error message, got: {msg}"
2793                );
2794                assert!(
2795                    msg.contains("NIS >= 1"),
2796                    "expected 'NIS >= 1' guidance in error message, got: {msg}"
2797                );
2798            }
2799            other => panic!("expected UnsupportedFormat for NIS=0, got {other:?}"),
2800        }
2801    }
2802
2803    fn file32_fixture() -> String {
2804        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2805            .join("../../tests/data/endf/Ta-181-file32.endf");
2806        std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path:?}: {e}"))
2807    }
2808
2809    #[test]
2810    fn test_file32_covariance_of_the_resonances_in_a_window() {
2811        let text = file32_fixture();
2812        let data = parse_endf_file2(&text).unwrap();
2813        let read = |window| parse_resonance_covariance(&text, &data, window).unwrap();
2814        let all = read(0.0..=100.0).expect("listed");
2815        assert_eq!(all.resonances, [[0, 0, 0], [0, 0, 1], [0, 0, 2], [0, 0, 3]]);
2816        assert_eq!(all.covariance[0], 1.229472e-6);
2817        let window = read(14.0..=30.0).expect("listed");
2818        assert_eq!(window.resonances, [[0, 0, 1], [0, 0, 2], [0, 0, 3]]);
2819        let at = |x: usize, y: usize| window.covariance[9 * x + y];
2820        for (x, y, expected) in [
2821            (0, 0, 1.558071e-6),
2822            (1, 2, -1.216048e-9),
2823            (0, 3, 6.691704e-9),
2824            (2, 7, 4.216599e-9),
2825            (5, 6, 8.726475e-9),
2826            (8, 8, 1.540406e-6),
2827        ] {
2828            assert_eq!((at(x, y), at(y, x)), (expected, expected), "{x}, {y}");
2829        }
2830        assert!(read(100.0..=200.0).is_none());
2831    }
2832
2833    #[test]
2834    fn test_file32_forms_not_read_are_refused() {
2835        let text = file32_fixture();
2836        let data = parse_endf_file2(&text).unwrap();
2837        let read = |text: &str| parse_resonance_covariance(text, &data, 0.0..=100.0);
2838        let edit = |text: &str, line: usize, field: usize, value: &str| {
2839            let change = |(k, l): (usize, &str)| match k + 1 == line {
2840                true => format!("{}{value:>11}{}\n", &l[..11 * field], &l[11 * field + 11..]),
2841                false => format!("{l}\n"),
2842            };
2843            text.lines().enumerate().map(change).collect::<String>()
2844        };
2845        let edited = |line: usize, field: usize, value: &str| edit(&text, line, field, value);
2846        let edits = |changes: &[(usize, usize, &str)]| {
2847            changes
2848                .iter()
2849                .fold(text.clone(), |text, &(l, f, v)| edit(&text, l, f, v))
2850        };
2851        let unresolved =
2852            " 2.554000+3 1.000000+5          2          1          0          0732832151
2853 3.500000+0 7.860000-1          0          0          1          0732832151
2854 1.793936+2 0.000000+0          0          0          6          1732832151
2855 1.000000+0 3.000000+0 1.000000-3 6.000000-2 0.000000+0 0.000000+0732832151
2856 0.000000+0 0.000000+0          1          0          1          1732832151
2857 1.000000-2                                                       732832151
2858";
2859        let head = |text: &str, n| text.split_inclusive('\n').take(n).collect::<String>();
2860        let only = head(&text, 11) + unresolved;
2861        for refused in [
2862            edit(&only, 11, 4, "2"),
2863            edit(&only, 12, 3, "2"),
2864            edits(&[
2865                (17, 0, "1.034682+1"),
2866                (17, 2, "4.816293-3"),
2867                (17, 3, "6.164143-2"),
2868            ]),
2869            edited(10, 0, "7.318200+4"),
2870            edited(11, 0, "7.318200+4"),
2871            edited(10, 4, "2"),
2872            edits(&[(10, 4, "2"), (12, 2, "2"), (12, 3, "1")]),
2873            edited(12, 2, "2"),
2874            edits(&[(11, 4, "2"), (12, 2, "2"), (12, 3, "1")]),
2875            edited(11, 4, "0"),
2876            edited(12, 2, "0"),
2877            edited(12, 3, "2"),
2878            edited(12, 4, "1"),
2879            edited(13, 3, "2"),
2880            edited(13, 5, "1"),
2881            edited(14, 5, "1"),
2882            edited(15, 2, "2"),
2883            edited(15, 4, "101"),
2884            edited(16, 0, "nan"),
2885            edited(17, 0, "2.031200+1"),
2886            edited(17, 1, "4.000000+0"),
2887            edited(17, 2, "1.066000-3"),
2888            edited(11, 4, "2") + unresolved + &unresolved[..76],
2889        ] {
2890            let refused = read(&refused);
2891            assert!(
2892                matches!(refused, Err(EndfParseError::UnsupportedFormat(_))),
2893                "{refused:?}"
2894            );
2895        }
2896        let all = read(&text).unwrap().expect("listed");
2897        for accepted in [
2898            edited(17, 0, "2.031171+1"),
2899            edited(17, 2, "1.065878-3"),
2900            edited(11, 4, "2") + unresolved,
2901        ] {
2902            assert_eq!(read(&accepted).unwrap().as_ref(), Some(&all));
2903        }
2904        assert!(read(&(edited(11, 4, "2") + &unresolved[..380])).is_err());
2905        let edge = parse_resonance_covariance(&edited(17, 0, "2.031171+1"), &data, 0.0..=20.3117);
2906        assert_eq!(
2907            edge.unwrap().expect("listed").resonances,
2908            all.resonances[..2]
2909        );
2910        let mut grouped: Vec<String> = edits(&[(4, 4, "2"), (5, 4, "12"), (5, 5, "2")])
2911            .lines()
2912            .map(String::from)
2913            .collect();
2914        let group =
2915            " 1.793936+2 7.860000-1          1          0         12          27328 2151    0";
2916        grouped.insert(7, group.to_string());
2917        let grouped = grouped.join("\n");
2918        let split = parse_endf_file2(&grouped).unwrap();
2919        let regrouped = parse_resonance_covariance(&grouped, &split, 0.0..=100.0).unwrap();
2920        let regrouped = regrouped.expect("listed");
2921        let indices = [[0, 0, 0], [0, 0, 1], [0, 1, 0], [0, 1, 1]];
2922        assert_eq!(regrouped.resonances, indices);
2923        assert_eq!(regrouped.covariance, all.covariance);
2924        let doublet = edits(&[
2925            (8, 0, "2.031170+1"),
2926            (8, 2, "1.065877-3"),
2927            (8, 3, "6.370451-2"),
2928        ]);
2929        let mut multilevel = data.clone();
2930        multilevel.ranges[0].formalism = ResonanceFormalism::MLBW;
2931        for (file, file2, window) in [
2932            (&text, multilevel, 0.0..=100.0),
2933            (&doublet, parse_endf_file2(&doublet).unwrap(), 15.0..=21.0),
2934        ] {
2935            let refused = parse_resonance_covariance(file, &file2, window);
2936            assert!(matches!(refused, Err(EndfParseError::UnsupportedFormat(_))));
2937        }
2938        for window in [f64::NAN..=1.0, 30.0..=14.0] {
2939            let refused = parse_resonance_covariance(&text, &data, window);
2940            assert!(matches!(refused, Err(EndfParseError::InvalidNumber(_))));
2941        }
2942        for empty in [head(&text, 9), only] {
2943            assert!(read(&empty).unwrap().is_none());
2944        }
2945        let field = |v: f64| format!("{:>11}", format!("{v:e}").replace('e', ""));
2946        let members = [[0, 1], [1, 2]];
2947        let block = |rows: [usize; 2]| {
2948            let at = |p: usize| 3 * rows[p / 3] + p % 3;
2949            let packed = (0..6).flat_map(|p| (p..6).map(move |q| (p, q)));
2950            let values: Vec<String> = packed
2951                .map(|(p, q)| field(all.covariance[12 * at(p) + at(q)]))
2952                .collect();
2953            let head = "        0.0        0.0          3          0         33          2".into();
2954            let table = rows.map(|r| text.lines().nth(15 + r).unwrap()[..66].to_string());
2955            let body = values.chunks(6).map(|c| c.concat()).collect::<Vec<_>>();
2956            std::iter::once(head)
2957                .chain(table)
2958                .chain(body)
2959                .map(|l| format!("{l:<66}732832151    0\n"))
2960                .collect::<String>()
2961        };
2962        let sections = head(&edited(14, 4, "2"), 14);
2963        let overlapping = sections + &members.map(block).concat();
2964        let summed = read(&overlapping).unwrap().expect("listed");
2965        assert_eq!(summed.resonances, all.resonances[..3]);
2966        for (a, b) in (0..9).flat_map(|a| (0..9).map(move |b| (a, b))) {
2967            let shared = members
2968                .iter()
2969                .filter(|m| m.contains(&(a / 3)) && m.contains(&(b / 3)));
2970            let expected = shared.count() as f64 * all.covariance[12 * a + b];
2971            assert_eq!(summed.covariance[9 * a + b], expected, "{a}, {b}");
2972        }
2973    }
2974}
2975
2976#[cfg(test)]
2977mod rml_tests {
2978    use super::*;
2979
2980    const PHOTON: [f64; 12] = [
2981        0.0, 183.37, 0.0, 74.0, 1.0, 0.0, 0.0, 0.0, 0.0, 102.0, 0.0, 0.0,
2982    ];
2983    const NEUTRON: [f64; 12] = [
2984        1.0, 182.37, 0.0, 74.0, 0.5, 0.0, 0.0, 1.0, 0.0, 2.0, 0.0, 1.0,
2985    ];
2986
2987    /// One KBK record: LCH, LBK and the LBK=2 list [ED, EU, R0, R1, R2, S0, S1].
2988    struct Background {
2989        lch: i64,
2990        lbk: i64,
2991        list: Option<[f64; 7]>,
2992    }
2993
2994    struct Group {
2995        aj: f64,
2996        pj: f64,
2997        kps: i64,
2998        channels: Vec<[f64; 6]>,
2999        resonances: Vec<Vec<f64>>,
3000        nx: Option<usize>,
3001        background: Vec<Background>,
3002    }
3003
3004    /// One-range LRF=7 MF=2 section written in the ENDF-102 §2.2.1.6 layout.
3005    struct Fixture {
3006        krm: i64,
3007        ifg: i64,
3008        krl: i64,
3009        nro: bool,
3010        pairs: Vec<[f64; 12]>,
3011        groups: Vec<Group>,
3012    }
3013
3014    impl Fixture {
3015        /// W-184-like evaluation: photon pair 1, neutron pair 2, one s-wave group.
3016        fn basic() -> Self {
3017            Self {
3018                krm: 3,
3019                ifg: 0,
3020                krl: 0,
3021                nro: false,
3022                pairs: vec![PHOTON, NEUTRON],
3023                groups: vec![Group {
3024                    aj: 0.5,
3025                    pj: 0.0,
3026                    kps: 0,
3027                    channels: vec![
3028                        [1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
3029                        [2.0, 0.0, 0.5, 0.0, 0.764, 0.75],
3030                    ],
3031                    resonances: vec![vec![-12.0, 0.05, 0.3], vec![101.9, 0.06, -0.02]],
3032                    nx: None,
3033                    background: vec![],
3034                }],
3035            }
3036        }
3037
3038        fn text(&self) -> String {
3039            fn float(x: f64) -> String {
3040                format!("{:>11}", format!("{x:.5E}"))
3041            }
3042            fn int(n: i64) -> String {
3043                format!("{n:>11}")
3044            }
3045            fn list(record: &mut dyn FnMut(Vec<String>), values: &[f64]) {
3046                for chunk in values.chunks(6) {
3047                    let mut fields: Vec<String> = chunk.iter().map(|&v| float(v)).collect();
3048                    fields.resize(6, " ".repeat(11));
3049                    record(fields);
3050                }
3051            }
3052            let mut lines: Vec<String> = Vec::new();
3053            let mut record = |fields: Vec<String>| {
3054                let seq = lines.len() + 1;
3055                lines.push(format!("{:66}7437 2151{seq:>5}", fields.concat()));
3056            };
3057            let zero = || float(0.0);
3058            record(vec![
3059                float(74184.0),
3060                float(182.37),
3061                int(0),
3062                int(0),
3063                int(1),
3064                int(0),
3065            ]);
3066            record(vec![
3067                float(74184.0),
3068                float(1.0),
3069                int(0),
3070                int(0),
3071                int(1),
3072                int(0),
3073            ]);
3074            record(vec![
3075                float(1e-5),
3076                float(1e4),
3077                int(1),
3078                int(7),
3079                int(i64::from(self.nro)),
3080                int(0),
3081            ]);
3082            if self.nro {
3083                record(vec![zero(), zero(), int(0), int(0), int(1), int(2)]);
3084                record(vec![
3085                    int(2),
3086                    int(2),
3087                    " ".repeat(11),
3088                    " ".repeat(11),
3089                    " ".repeat(11),
3090                    " ".repeat(11),
3091                ]);
3092                list(&mut record, &[1e-5, 0.7, 1e4, 0.7]);
3093            }
3094            record(vec![
3095                zero(),
3096                zero(),
3097                int(self.ifg),
3098                int(self.krm),
3099                int(self.groups.len() as i64),
3100                int(self.krl),
3101            ]);
3102            let npp = self.pairs.len() as i64;
3103            record(vec![
3104                zero(),
3105                zero(),
3106                int(npp),
3107                int(0),
3108                int(12 * npp),
3109                int(2 * npp),
3110            ]);
3111            list(&mut record, &self.pairs.concat());
3112            for g in &self.groups {
3113                let nch = g.channels.len();
3114                record(vec![
3115                    float(g.aj),
3116                    float(g.pj),
3117                    int(g.background.len() as i64),
3118                    int(g.kps),
3119                    int(6 * nch as i64),
3120                    int(nch as i64),
3121                ]);
3122                list(&mut record, &g.channels.concat());
3123                let per = 6 * (nch / 6 + 1);
3124                let mut values: Vec<f64> = Vec::new();
3125                for r in &g.resonances {
3126                    let mut row = r.clone();
3127                    row.resize(per, 0.0);
3128                    values.extend(row);
3129                }
3130                if g.resonances.is_empty() {
3131                    values.resize(6, 0.0);
3132                }
3133                let nx = g.nx.unwrap_or(values.len() / 6);
3134                values.resize(6 * nx, 0.0);
3135                record(vec![
3136                    zero(),
3137                    zero(),
3138                    int(0),
3139                    int(g.resonances.len() as i64),
3140                    int(6 * nx as i64),
3141                    int(nx as i64),
3142                ]);
3143                list(&mut record, &values);
3144                for b in &g.background {
3145                    record(vec![zero(), zero(), int(b.lch), int(b.lbk), int(0), int(0)]);
3146                    if let Some(v) = b.list {
3147                        record(vec![
3148                            float(v[0]),
3149                            float(v[1]),
3150                            int(0),
3151                            int(0),
3152                            int(5),
3153                            int(0),
3154                        ]);
3155                        list(&mut record, &v[2..]);
3156                    }
3157                }
3158            }
3159            lines.join("\n")
3160        }
3161
3162        fn parse(&self) -> Result<ResonanceData, EndfParseError> {
3163            parse_file2_records(&self.text())
3164        }
3165    }
3166
3167    fn rml(data: &ResonanceData) -> &RmlRange {
3168        data.ranges[0]
3169            .r_matrix_limited
3170            .as_ref()
3171            .expect("parsed LRF=7 payload")
3172    }
3173
3174    fn assert_rejects(fixture: &Fixture, needle: &str) {
3175        match fixture.parse() {
3176            Err(EndfParseError::UnsupportedFormat(msg)) => {
3177                assert!(msg.contains(needle), "expected '{needle}' in: {msg}");
3178            }
3179            other => panic!("expected UnsupportedFormat with '{needle}', got {other:?}"),
3180        }
3181    }
3182
3183    #[test]
3184    fn parses_pairs_channels_and_widths() {
3185        let data = Fixture::basic().parse().unwrap();
3186        let range = &data.ranges[0];
3187        assert_eq!(range.formalism, ResonanceFormalism::RMatrixLimited);
3188        let rml = rml(&data);
3189        assert_eq!((rml.photon_pair, rml.incident_pair), (0, 1));
3190        assert!(
3191            !rml.pairs[0].penetrability,
3192            "PNT=0 on MT=102 means no penetrability"
3193        );
3194        assert!(rml.pairs[1].penetrability);
3195        let group = &rml.spin_groups[0];
3196        assert_eq!((group.j, group.parity), (0.5, 1));
3197        assert_eq!(group.channels[1].pair, 1);
3198        assert!((group.channels[1].radius_phase - 7.64).abs() < 1e-12);
3199        assert!((group.channels[1].radius_true - 7.5).abs() < 1e-12);
3200        assert_eq!(group.resonances.len(), 2);
3201        assert_eq!(group.resonances[1].energy, 101.9);
3202        assert_eq!(group.resonances[1].widths, vec![0.06, -0.02]);
3203        assert_eq!(rml.resonance_count(), 2);
3204    }
3205
3206    /// ENDF-6 defines PNT=-1 as "do not calculate the penetrability".
3207    #[test]
3208    fn accepts_pnt_minus_one_on_photon_pair() {
3209        let mut fixture = Fixture::basic();
3210        fixture.pairs[0][7] = -1.0;
3211        let data = fixture.parse().unwrap();
3212        assert!(!rml(&data).pairs[0].penetrability);
3213    }
3214
3215    #[test]
3216    fn pnt_zero_takes_the_mt_default() {
3217        let mut fixture = Fixture::basic();
3218        fixture.pairs[1][7] = 0.0;
3219        let data = fixture.parse().unwrap();
3220        assert!(rml(&data).pairs[1].penetrability, "MT=2 defaults to PNT=1");
3221    }
3222
3223    #[test]
3224    fn rejects_undefined_or_fractional_pnt() {
3225        let mut fixture = Fixture::basic();
3226        fixture.pairs[1][7] = 2.0;
3227        assert_rejects(&fixture, "PNT=2 is not defined");
3228        fixture.pairs[1][7] = 1.5;
3229        assert_rejects(&fixture, "PNT=1.5 is not an integer");
3230    }
3231
3232    #[test]
3233    fn rejects_penetrability_on_photon_pair() {
3234        let mut fixture = Fixture::basic();
3235        fixture.pairs[0][7] = 1.0;
3236        assert_rejects(
3237            &fixture,
3238            "photon pair (MA=0) cannot calculate the penetrability",
3239        );
3240    }
3241
3242    #[test]
3243    fn rejects_penetrating_pair_without_mass() {
3244        let mut fixture = Fixture::basic();
3245        fixture.pairs[1][1] = 0.0;
3246        assert_rejects(&fixture, "needs positive masses");
3247    }
3248
3249    #[test]
3250    fn rejects_unsupported_header_flags() {
3251        let mut fixture = Fixture::basic();
3252        fixture.krm = 2;
3253        assert_rejects(&fixture, "KRM=2 is not supported");
3254        let mut fixture = Fixture::basic();
3255        fixture.ifg = 1;
3256        assert_rejects(&fixture, "IFG=1");
3257        let mut fixture = Fixture::basic();
3258        fixture.krl = 1;
3259        assert_rejects(&fixture, "KRL=1");
3260        let mut fixture = Fixture::basic();
3261        fixture.nro = true;
3262        assert_rejects(&fixture, "NRO=1");
3263        let mut fixture = Fixture::basic();
3264        fixture.groups[0].kps = 1;
3265        assert_rejects(&fixture, "KPS=1");
3266    }
3267
3268    #[test]
3269    fn accepts_charged_and_rejects_fission_pairs() {
3270        let mut fixture = Fixture::basic();
3271        let mut proton = NEUTRON;
3272        proton[0] = 0.9986235;
3273        proton[2] = 1.0;
3274        proton[3] = 73.0;
3275        proton[6] = 6.15e5;
3276        proton[9] = 600.0;
3277        fixture.pairs.push(proton);
3278        let data = fixture.parse().unwrap();
3279        let pair = &rml(&data).pairs[2];
3280        assert_eq!((pair.charge_a, pair.charge_b, pair.mt), (1.0, 73.0, 600));
3281        let mut fixture = Fixture::basic();
3282        let mut fission = PHOTON;
3283        fission[0] = 1.0;
3284        fission[9] = 18.0;
3285        fission[7] = -1.0;
3286        fixture.pairs.push(fission);
3287        assert_rejects(&fixture, "fission pair (MT=18)");
3288    }
3289
3290    #[test]
3291    fn shift_factor_needs_an_open_channel() {
3292        let mut fixture = Fixture::basic();
3293        fixture.pairs[1][8] = 1.0;
3294        let data = fixture.parse().unwrap();
3295        assert!(
3296            rml(&data).pairs[1].shift,
3297            "SHF=1 on the incident pair is open everywhere"
3298        );
3299
3300        let mut fixture = Fixture::basic();
3301        let mut inelastic = NEUTRON;
3302        inelastic[6] = -1.0e3;
3303        inelastic[8] = 1.0;
3304        inelastic[9] = 51.0;
3305        fixture.pairs.push(inelastic);
3306        assert_rejects(&fixture, "SHF=1 on a channel with threshold");
3307    }
3308
3309    /// The eliminated channel is the photon pair's channel at any position.
3310    #[test]
3311    fn finds_photon_channel_by_pair_not_position() {
3312        let mut fixture = Fixture::basic();
3313        fixture.groups[0].channels.swap(0, 1);
3314        for r in &mut fixture.groups[0].resonances {
3315            r.swap(1, 2);
3316        }
3317        let data = fixture.parse().unwrap();
3318        let group = &rml(&data).spin_groups[0];
3319        assert_eq!(group.channels[1].pair, 0);
3320        assert_eq!(group.resonances[1].widths, vec![-0.02, 0.06]);
3321    }
3322
3323    #[test]
3324    fn rejects_missing_or_duplicate_photon_and_entrance_channels() {
3325        let mut fixture = Fixture::basic();
3326        fixture.pairs[0][7] = -1.0;
3327        fixture.pairs[0][9] = 103.0;
3328        assert_rejects(&fixture, "exactly one photon pair");
3329        let mut fixture = Fixture::basic();
3330        fixture.groups[0].channels.truncate(1);
3331        for r in &mut fixture.groups[0].resonances {
3332            r.truncate(2);
3333        }
3334        assert_rejects(&fixture, "has no neutron entrance channel");
3335        let mut fixture = Fixture::basic();
3336        fixture.groups[0].channels[0] = fixture.groups[0].channels[1];
3337        assert_rejects(&fixture, "exactly one channel of the photon pair");
3338        let mut fixture = Fixture::basic();
3339        fixture.groups[0].channels[1][0] = 1.0;
3340        assert_rejects(&fixture, "needs exactly one channel of the photon pair");
3341        let mut fixture = Fixture::basic();
3342        fixture.groups[0].channels[1][0] = 3.0;
3343        assert_rejects(&fixture, "PPI=3 is out of range");
3344    }
3345
3346    #[test]
3347    fn rejects_resonance_on_a_penetrating_threshold() {
3348        let mut fixture = Fixture::basic();
3349        fixture.groups[0].resonances[0][0] = 0.0;
3350        assert_rejects(&fixture, "sits on the threshold");
3351    }
3352
3353    #[test]
3354    fn negative_aj_and_pj_give_negative_parity() {
3355        let mut fixture = Fixture::basic();
3356        fixture.groups[0].aj = -1.5;
3357        let data = fixture.parse().unwrap();
3358        assert_eq!(
3359            (
3360                rml(&data).spin_groups[0].j,
3361                rml(&data).spin_groups[0].parity
3362            ),
3363            (1.5, -1)
3364        );
3365        let mut fixture = Fixture::basic();
3366        fixture.groups[0].aj = 0.0;
3367        fixture.groups[0].pj = -1.0;
3368        let data = fixture.parse().unwrap();
3369        assert_eq!(rml(&data).spin_groups[0].parity, -1);
3370    }
3371
3372    #[test]
3373    fn empty_group_keeps_one_filler_line() {
3374        let mut fixture = Fixture::basic();
3375        fixture.groups[0].resonances.clear();
3376        let data = fixture.parse().unwrap();
3377        assert!(rml(&data).spin_groups[0].resonances.is_empty());
3378        fixture.groups[0].nx = Some(2);
3379        assert_rejects(&fixture, "NX=2");
3380    }
3381
3382    /// Six or more channels put each resonance on NCH/6+1 lines.
3383    #[test]
3384    fn wide_group_reads_two_lines_per_resonance() {
3385        let mut fixture = Fixture::basic();
3386        let mut channels = vec![[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]];
3387        for l in 0..5 {
3388            channels.push([2.0, f64::from(l), 0.5, 0.0, 0.7, 0.7]);
3389        }
3390        let group = &mut fixture.groups[0];
3391        group.channels = channels;
3392        group.resonances = vec![
3393            vec![5.0, 0.04, 1.0, 2.0, 3.0, 4.0, 5.0],
3394            vec![9.0, 0.05, 6.0, 7.0, 8.0, 9.0, 10.0],
3395        ];
3396        let data = fixture.parse().unwrap();
3397        let group = &rml(&data).spin_groups[0];
3398        assert_eq!(group.resonances[1].energy, 9.0);
3399        assert_eq!(
3400            group.resonances[1].widths,
3401            vec![0.05, 6.0, 7.0, 8.0, 9.0, 10.0]
3402        );
3403        fixture.groups[0].nx = Some(2);
3404        assert_rejects(&fixture, "need NX=4");
3405    }
3406
3407    #[test]
3408    fn reads_logarithmic_background() {
3409        let mut fixture = Fixture::basic();
3410        fixture.groups[0].background = vec![Background {
3411            lch: 2,
3412            lbk: 2,
3413            list: Some([0.0, 2.0e4, -0.043, 1.0e-6, 2.0e-9, 0.01, 3.0e-7]),
3414        }];
3415        let data = fixture.parse().unwrap();
3416        let background = &rml(&data).spin_groups[0].background;
3417        assert_eq!(background.len(), 1);
3418        let b = &background[0];
3419        assert_eq!(b.channel, 1);
3420        assert_eq!((b.e_down, b.e_up), (0.0, 2.0e4));
3421        assert_eq!(
3422            [b.r_con, b.r_lin, b.r_quad, b.s_con, b.s_lin],
3423            [-0.043, 1.0e-6, 2.0e-9, 0.01, 3.0e-7]
3424        );
3425    }
3426
3427    #[test]
3428    fn lbk_zero_adds_no_term() {
3429        let mut fixture = Fixture::basic();
3430        fixture.groups[0].background = vec![Background {
3431            lch: 2,
3432            lbk: 0,
3433            list: None,
3434        }];
3435        let data = fixture.parse().unwrap();
3436        assert!(rml(&data).spin_groups[0].background.is_empty());
3437    }
3438
3439    #[test]
3440    fn rejects_unsupported_or_misplaced_background() {
3441        let background = |lch, lbk, list| {
3442            let mut fixture = Fixture::basic();
3443            fixture.groups[0].background = vec![Background { lch, lbk, list }];
3444            fixture
3445        };
3446        let list = Some([0.0, 2.0e4, 0.1, 0.0, 0.0, 0.0, 0.0]);
3447        assert_rejects(&background(2, 1, None), "LBK=1 background is not supported");
3448        assert_rejects(&background(2, 3, None), "LBK=3 background is not supported");
3449        assert_rejects(&background(1, 2, list), "eliminated photon channel");
3450        assert_rejects(&background(3, 2, list), "LCH=3 is out of range");
3451        assert_rejects(
3452            &background(2, 2, Some([1.0, 2.0e4, 0.1, 0.0, 0.0, 0.0, 0.0])),
3453            "ED < EL, EU > EH",
3454        );
3455        let mut fixture = background(2, 0, None);
3456        fixture.groups[0].background.push(Background {
3457            lch: 2,
3458            lbk: 2,
3459            list,
3460        });
3461        assert_rejects(&fixture, "given twice for channel 2");
3462    }
3463
3464    fn mf2_fixture(name: &str) -> String {
3465        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3466            .join("../../tests/data/endf/mf2")
3467            .join(format!("{name}.endf"));
3468        std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path:?}: {e}"))
3469    }
3470
3471    /// Each ENDF/B-VIII.1 LRF=7 fixture parses to the end with the counts its
3472    /// records state (resonances per isotope, from the files' NRS fields).
3473    #[test]
3474    fn parses_endf_b_viii_1_lrf7_evaluations() {
3475        let cases = [
3476            ("W-182", 3, 312),
3477            ("W-183", 5, 374),
3478            ("W-184", 3, 218),
3479            ("W-186", 3, 175),
3480            ("Fe-54", 5, 737),
3481            ("Fe-57", 5, 123),
3482            ("Si-28", 7, 55),
3483            ("Si-29", 10, 31),
3484            ("Si-30", 5, 30),
3485            ("Rh-103", 5, 555),
3486            ("Cu-63", 6, 1172),
3487            ("Cu-65", 6, 969),
3488            ("V-51", 6, 136),
3489            ("Sr-88", 7, 443),
3490            ("Cl-35", 8, 272),
3491            ("Ca-40", 5, 246),
3492        ];
3493        for (name, groups, resonances) in cases {
3494            let data =
3495                parse_file2_records(&mf2_fixture(name)).unwrap_or_else(|e| panic!("{name}: {e}"));
3496            let rml = data
3497                .ranges
3498                .iter()
3499                .find_map(|r| r.r_matrix_limited.as_ref())
3500                .unwrap_or_else(|| panic!("{name}: no LRF=7 range"));
3501            assert_eq!(rml.spin_groups.len(), groups, "{name}");
3502            assert_eq!(rml.resonance_count(), resonances, "{name}");
3503            assert_eq!(rml.pairs[rml.photon_pair].mt, 102, "{name}");
3504            assert!(!rml.pairs[rml.photon_pair].penetrability, "{name}");
3505        }
3506    }
3507
3508    /// Sr-88 gives a logarithmic background (KBK=1, LBK=2) in every group.
3509    #[test]
3510    fn parses_sr88_backgrounds() {
3511        let data = parse_file2_records(&mf2_fixture("Sr-88")).unwrap();
3512        let rml = rml(&data);
3513        assert_eq!(rml.pairs[0].mt, 102);
3514        assert!(
3515            !rml.pairs[0].penetrability,
3516            "Sr-88 writes PNT=-1 on the photon pair"
3517        );
3518        for group in &rml.spin_groups {
3519            assert_eq!(group.background.len(), 1);
3520            assert_eq!(group.channels[group.background[0].channel].pair, 1);
3521        }
3522        let first = &rml.spin_groups[0].background[0];
3523        assert_eq!((first.e_down, first.e_up), (0.0, 9.55e5));
3524        assert_eq!((first.r_con, first.s_con, first.s_lin), (-0.043, 0.01, 0.0));
3525    }
3526
3527    /// Cu-63 calculates the shift factor on its neutron pair with B = -1 for l = 1.
3528    #[test]
3529    fn parses_cu63_shift_and_boundary() {
3530        let data = parse_file2_records(&mf2_fixture("Cu-63")).unwrap();
3531        let rml = rml(&data);
3532        assert!(rml.pairs[rml.incident_pair].shift);
3533        let p_wave = rml
3534            .spin_groups
3535            .iter()
3536            .flat_map(|g| &g.channels)
3537            .find(|c| c.pair == rml.incident_pair && c.l == 1)
3538            .unwrap();
3539        assert_eq!(p_wave.boundary, -1.0);
3540    }
3541
3542    /// Fe-57's inelastic pair (MT=51, Q = -14.413 keV) is a closed channel
3543    /// over the VENUS band and carries no shift factor.
3544    #[test]
3545    fn parses_fe57_inelastic_pair() {
3546        let data = parse_file2_records(&mf2_fixture("Fe-57")).unwrap();
3547        let rml = rml(&data);
3548        let inelastic = rml.pairs.iter().find(|p| p.mt == 51).unwrap();
3549        assert_eq!(inelastic.q_value, -14413.0);
3550        assert!(inelastic.penetrability && !inelastic.shift);
3551    }
3552}