Skip to main content

nereids_endf/
parser.rs

1//! ENDF-6 File 2 resonance parameter parser.
2//!
3//! Parses the fixed-width 80-character ENDF-6 format to extract resolved
4//! resonance region (RRR) parameters.
5//!
6//! ## ENDF-6 Line Format
7//! Each line is exactly 80 characters:
8//! - Cols 1-11:  Field 1 (floating point or integer)
9//! - Cols 12-22: Field 2
10//! - Cols 23-33: Field 3
11//! - Cols 34-44: Field 4
12//! - Cols 45-55: Field 5
13//! - Cols 56-66: Field 6
14//! - Cols 67-70: MAT number
15//! - Cols 71-72: MF (file number)
16//! - Cols 73-75: MT (section number)
17//! - Cols 76-80: Line sequence
18//!
19//! ## SAMMY Reference
20//! - SAMMY manual Section 9 (ENDF-6 format)
21//! - SAMMY source: `sammy/src/endf/` module
22
23use crate::resonance::*;
24use nereids_core::elements::isotope_from_za;
25
26/// ENDF radius unit conversion factor.
27///
28/// ENDF-6 Formats Manual §2.1: "AP, APE, APT are in units of 10⁻¹² cm."
29/// Physics convention: 1 fm = 10⁻¹³ cm, so 10⁻¹² cm = 10 fm.
30/// All ENDF radii (AP, APL, APE, APT, AP(E) tables) are multiplied by this
31/// factor at parse time so that downstream physics uses true femtometers.
32///
33/// SAMMY applies the identical ×10 conversion when reading ENDF:
34///   `FillSammyRmatrixFromRMat.cpp` line 422: `newChannel->getApe() * 10.0`
35const ENDF_RADIUS_TO_FM: f64 = 10.0;
36
37/// Parse ENDF-6 File 2 resonance parameters from raw ENDF text.
38///
39/// Extracts all MF=2, MT=151 lines and parses the resolved resonance region.
40///
41/// # Arguments
42/// * `endf_text` — Full ENDF file contents as a string.
43///
44/// # Returns
45/// `ResonanceData` containing all parsed resonance parameters.
46pub fn parse_endf_file2(endf_text: &str) -> Result<ResonanceData, EndfParseError> {
47    // Extract MF=2, MT=151 lines (resonance parameters).
48    let lines: Vec<&str> = endf_text
49        .lines()
50        .filter(|line| {
51            if line.len() < 75 {
52                return false;
53            }
54            let mf = line[70..72].trim();
55            let mt = line[72..75].trim();
56            mf == "2" && mt == "151"
57        })
58        .collect();
59
60    if lines.is_empty() {
61        return Err(EndfParseError::MissingSection(
62            "No MF=2, MT=151 data found".to_string(),
63        ));
64    }
65
66    let mut pos = 0;
67
68    // HEAD record: ZA, AWR, 0, 0, NIS, 0
69    let head = parse_cont(&lines, &mut pos)?;
70    let za = head.c1 as u32;
71    let awr = head.c2;
72    let nis = checked_count(head.n1, "NIS")?; // number of isotopes (usually 1)
73
74    // ENDF-6 §2.1 requires NIS >= 1 for a valid resonance evaluation. NIS=0
75    // would leave the parser with no isotope subsection to read and fall
76    // through to a confusing "unconsumed data lines" downstream failure;
77    // reject up-front with a clear message.
78    if nis == 0 {
79        return Err(EndfParseError::UnsupportedFormat(
80            "MF=2 NIS=0: no isotopes declared. ENDF-6 §2.1 requires NIS >= 1 \
81             for a valid resonance evaluation."
82                .into(),
83        ));
84    }
85    // ENDF-6 §2.1: a material with NIS>1 contains multiple isotope subsections,
86    // each carrying its own ZAI, ABN, LFW, and NER ranges (e.g. natural-element
87    // evaluations such as nat-C with ZAI={6012,6013}). The reference reader
88    // OpenScale (File2.cpp:71-87) stores these in a Vec<ResonanceIsotope>
89    // tagged with per-isotope ABN, and downstream physics combines the per-
90    // isotope cross sections with that ABN weighting. NEREIDS's `ResonanceData`
91    // has no per-isotope discriminator and no abundance field; silently
92    // flattening multi-isotope subsections into one flat range list would
93    // discard ZAI/ABN and produce abundance-blind cross sections. Reject
94    // the case explicitly until a proper multi-isotope container is wired
95    // through. Single-isotope (NIS=1) ENDF evaluations remain fully supported.
96    if nis > 1 {
97        return Err(EndfParseError::UnsupportedFormat(format!(
98            "MF=2 NIS={nis} > 1 multi-isotope materials are not supported. \
99             Each NEREIDS ResonanceData represents a single isotope. \
100             For multi-isotope ENDF evaluations, split the material into per-isotope \
101             files or use sammy_to_resonance_data_multi from nereids_endf::sammy."
102        )));
103    }
104
105    let isotope = isotope_from_za(za)?;
106    let mut all_ranges = Vec::new();
107
108    for _ in 0..nis {
109        // Isotope CONT: ZAI, ABN, 0, LFW, NER, 0
110        let iso_cont = parse_cont(&lines, &mut pos)?;
111        let _zai = iso_cont.c1 as u32;
112        let _abn = iso_cont.c2; // abundance
113        let lfw = iso_cont.l2; // fission width flag (LFW=1 → energy-dependent fission widths in URR)
114        let ner = checked_count(iso_cont.n1, "NER")?; // number of energy ranges
115
116        for _ in 0..ner {
117            // Range CONT: EL, EH, LRU, LRF, NRO, NAPS
118            let range_cont = parse_cont(&lines, &mut pos)?;
119            let energy_low = range_cont.c1;
120            let energy_high = range_cont.c2;
121            let lru = range_cont.l1; // 1=resolved, 2=unresolved
122            let lrf = range_cont.l2; // resonance formalism
123
124            if lru == 2 {
125                // Unresolved resonance region (LRU=2).
126                // URR uses average level-spacing/width parameters; cross-sections are
127                // computed via Hauser-Feshbach in nereids_physics::urr.
128                //
129                // Unsupported sub-formats are skipped gracefully so that the resolved
130                // resonance ranges in the same evaluation remain accessible.
131                // Hard errors are reserved for genuinely malformed records.
132
133                // NRO=range_cont.n1: if non-zero a TAB1 AP(E) record immediately follows
134                // the range CONT before the URR SPI/AP/NLS CONT.
135                // ENDF-6 §2.2.2.
136                let nro_urr = range_cont.n1;
137                let naps_urr = range_cont.n2; // scattering radius calculation flag
138                let ap_table_urr = if nro_urr != 0 {
139                    let mut tab = parse_tab1(&lines, &mut pos)?;
140                    // AP(E) y-values are in 10⁻¹² cm; convert to fm.
141                    for pt in &mut tab.points {
142                        pt.1 *= ENDF_RADIUS_TO_FM;
143                    }
144                    Some(tab)
145                } else {
146                    None
147                };
148
149                // LRF=1 and LRF=2 with LFW=0 are fully supported.
150                // Unsupported combinations are skipped so that the resolved
151                // resonance ranges in the same evaluation remain accessible.
152                if lrf != 1 && lrf != 2 {
153                    skip_urr_body(&lines, &mut pos)?;
154                    continue;
155                }
156
157                // LFW=1 (energy-dependent fission widths):
158                // LRF=2: record layout identical to LFW=0 — handled below.
159                // LRF=1: different record layout (shared energy LIST).
160                // Reference: ENDF-6 §2.2.2.1 Case B; SAMMY File2Unres.f90.
161                if lfw != 0 && lrf == 1 {
162                    let mut urr_ctx = RangeParseContext {
163                        lines: &lines,
164                        pos: &mut pos,
165                        energy_low,
166                        energy_high,
167                        naps: naps_urr,
168                        ap_table: ap_table_urr,
169                    };
170                    let urr_range = parse_urr_lfw1_lrf1(&mut urr_ctx)?;
171                    all_ranges.push(urr_range);
172                    continue;
173                }
174
175                let mut urr_ctx = RangeParseContext {
176                    lines: &lines,
177                    pos: &mut pos,
178                    energy_low,
179                    energy_high,
180                    naps: naps_urr,
181                    ap_table: ap_table_urr,
182                };
183                let urr_range = parse_urr_range(&mut urr_ctx, lrf)?;
184                all_ranges.push(urr_range);
185                continue;
186            }
187
188            if lru == 0 {
189                // LRU=0: scattering-radius-only range (no resonance parameters).
190                // ENDF-6 §2.2: after the range CONT (and optional TAB1 if NRO!=0),
191                // a single CONT record follows: [SPI, AP, 0, 0, NLS=0, 0].
192                // We consume the NRO TAB1 if present, then the CONT, and skip.
193                let nro_lru0 = range_cont.n1;
194                if nro_lru0 != 0 {
195                    // TAB1 AP(E) already follows; consume it.
196                    let _tab = parse_tab1(&lines, &mut pos)?;
197                }
198                // CONT: SPI, AP, 0, 0, NLS=0, 0
199                // Validate NLS=0 (#123): a non-zero NLS in an LRU=0 range is
200                // malformed and would cause the parser to look for L-groups that
201                // don't exist, misaligning the cursor for subsequent ranges.
202                let spi_cont = parse_cont(&lines, &mut pos)?;
203                // ENDF-6 §2.2: the SPI/AP CONT is [SPI, AP, 0, 0, NLS=0, 0].
204                // Validate that L1 and L2 are both zero — non-zero values
205                // indicate a malformed or mis-identified record.
206                if spi_cont.l1 != 0 {
207                    return Err(EndfParseError::UnsupportedFormat(format!(
208                        "LRU=0 range: L1={} in SPI/AP CONT record must be 0",
209                        spi_cont.l1
210                    )));
211                }
212                if spi_cont.l2 != 0 {
213                    return Err(EndfParseError::UnsupportedFormat(format!(
214                        "LRU=0 range: L2={} in SPI/AP CONT record must be 0",
215                        spi_cont.l2
216                    )));
217                }
218                if spi_cont.n1 != 0 {
219                    return Err(EndfParseError::UnsupportedFormat(format!(
220                        "LRU=0 range: NLS={} in SPI/AP CONT record must be 0 \
221                         (scattering-radius-only ranges have no L-groups)",
222                        spi_cont.n1
223                    )));
224                }
225                if spi_cont.n2 != 0 {
226                    return Err(EndfParseError::UnsupportedFormat(format!(
227                        "LRU=0 range: N2={} in SPI/AP CONT record must be 0",
228                        spi_cont.n2
229                    )));
230                }
231                continue;
232            }
233
234            if lru != 1 {
235                return Err(EndfParseError::UnsupportedFormat(format!(
236                    "LRU={} not supported (expected 0=scattering-radius-only, 1=resolved, or 2=unresolved)",
237                    lru
238                )));
239            }
240
241            let nro = range_cont.n1; // energy-dependent scattering radius flag
242            let naps = range_cont.n2; // scattering radius calculation flag
243
244            // If NRO != 0, a TAB1 record immediately follows giving AP(E).
245            // Parse and store it; scattering_radius_at(E) will interpolate it
246            // at each energy point.  Reference: ENDF-6 §2.2.1; SAMMY mlb/mmlb1.f90.
247            let ap_table = if nro != 0 {
248                let mut tab = parse_tab1(&lines, &mut pos)?;
249                // AP(E) y-values are in 10⁻¹² cm; convert to fm.
250                for pt in &mut tab.points {
251                    pt.1 *= ENDF_RADIUS_TO_FM;
252                }
253                Some(tab)
254            } else {
255                None
256            };
257
258            // ENDF-6 Formats Manual: LRF values for resolved resonance region
259            // LRF=1: Single-Level Breit-Wigner (SLBW)
260            // LRF=2: Multi-Level Breit-Wigner (MLBW)
261            // LRF=3: Reich-Moore
262            // LRF=4: Adler-Adler (deprecated, not supported)
263            // LRF=7: R-Matrix Limited (general)
264            let formalism = match lrf {
265                1 => ResonanceFormalism::SLBW,
266                2 => ResonanceFormalism::MLBW,
267                3 => ResonanceFormalism::ReichMoore,
268                7 => ResonanceFormalism::RMatrixLimited,
269                _ => {
270                    return Err(EndfParseError::UnsupportedFormat(format!(
271                        "LRF={} not yet supported",
272                        lrf
273                    )));
274                }
275            };
276
277            let mut ctx = RangeParseContext {
278                lines: &lines,
279                pos: &mut pos,
280                energy_low,
281                energy_high,
282                naps,
283                ap_table,
284            };
285            let range = match formalism {
286                ResonanceFormalism::MLBW | ResonanceFormalism::SLBW => {
287                    parse_bw_range(&mut ctx, formalism)?
288                }
289                ResonanceFormalism::ReichMoore => parse_reich_moore_range(&mut ctx)?,
290                ResonanceFormalism::RMatrixLimited => parse_rmatrix_limited_range(&mut ctx, awr)?,
291                ResonanceFormalism::Unresolved => {
292                    // Unreachable: Unresolved is only assigned in the LRU=2 branch above.
293                    unreachable!("Unresolved formalism should not appear in LRU=1 dispatch");
294                }
295            };
296            all_ranges.push(range);
297        }
298    }
299
300    // Multi-MAT detection (#114, #123): since `lines` is pre-filtered to
301    // MF=2/MT=151, any unconsumed lines are definitively from another material.
302    // The previous character-based heuristic for distinguishing "real data" from
303    // SEND/FEND/MEND/TEND records was overly complex — those section-end records
304    // use different MF/MT codes and are already excluded by the filter above.
305    //
306    // Assumption: trailing whitespace-only lines that happen to pass the MF/MT
307    // filter (i.e. have " 2" at cols 70-72 and "151" at cols 72-75) would also
308    // trigger this check.  In practice, ENDF files do not contain such lines —
309    // trailing blanks either lack the MF/MT fields entirely or use MF=0/MT=0,
310    // both of which are excluded by the filter in `parse_endf_file2`.
311    if pos < lines.len() {
312        return Err(EndfParseError::UnsupportedFormat(
313            "Multiple materials detected in MF=2/MT=151: unconsumed data lines \
314             remain after parsing the first material. Multi-MAT files are not \
315             supported; split the file into single-material ENDF files."
316                .to_string(),
317        ));
318    }
319
320    Ok(ResonanceData {
321        isotope,
322        za,
323        awr,
324        ranges: all_ranges,
325    })
326}
327
328/// Shared context for ENDF range parsers.
329///
330/// Groups the file-position state (`lines`, `pos`) with the fields from the
331/// range CONT record that every range parser needs, eliminating long argument
332/// lists.
333struct RangeParseContext<'a> {
334    lines: &'a [&'a str],
335    pos: &'a mut usize,
336    energy_low: f64,
337    energy_high: f64,
338    naps: i32,
339    ap_table: Option<Tab1>,
340}
341
342/// Parse a Breit-Wigner (SLBW or MLBW) resolved resonance range.
343///
344/// ENDF-6 File 2, LRF=1 (SLBW) / LRF=2 (MLBW):
345/// - CONT: SPI, AP, 0, 0, NLS, 0
346/// - For each L-value:
347///   - CONT: AWRI, 0.0, L, 0, 6*NRS, NRS
348///   - LIST: NRS resonances, each 6 values: ER, AJ, GT, GN, GG, GF
349///
350/// Reference: ENDF-6 Formats Manual Section 2.2.1.1
351fn parse_bw_range(
352    ctx: &mut RangeParseContext<'_>,
353    formalism: ResonanceFormalism,
354) -> Result<ResonanceRange, EndfParseError> {
355    // CONT: SPI, AP, 0, 0, NLS, 0
356    // ENDF AP is in 10⁻¹² cm; convert to fm (×10).
357    let cont = parse_cont(ctx.lines, ctx.pos)?;
358    let target_spin = cont.c1;
359    let scattering_radius = cont.c2 * ENDF_RADIUS_TO_FM;
360    let nls = checked_count(cont.n1, "NLS")?; // number of L-values
361
362    let mut l_groups = Vec::with_capacity(nls);
363
364    for _ in 0..nls {
365        // CONT: AWRI, QX, L, LRX, 6*NRS, NRS
366        let l_cont = parse_cont(ctx.lines, ctx.pos)?;
367        let awr_l = l_cont.c1;
368        let qx = l_cont.c2; // Q-value for competitive width (eV)
369        // Validate L is non-negative (#123): negative L1 wraps to a huge u32.
370        if l_cont.l1 < 0 {
371            return Err(EndfParseError::UnsupportedFormat(format!(
372                "BW range: negative L={}",
373                l_cont.l1
374            )));
375        }
376        let l_val = l_cont.l1 as u32;
377        let lrx = l_cont.l2; // competitive width flag
378        let n1 = checked_count(l_cont.n1, "N1")?; // should be 6*NRS
379        let nrs = checked_count(l_cont.n2, "NRS")?; // number of resonances
380
381        // Validate N1 == 6*NRS (#123): a mismatch means the record is malformed
382        // and reading N1 values would over-/under-consume lines.
383        if n1 != 6 * nrs {
384            return Err(EndfParseError::UnsupportedFormat(format!(
385                "BW range L={l_val}: N1={n1} != 6*NRS={} (NRS={nrs})",
386                6 * nrs
387            )));
388        }
389
390        let mut resonances = Vec::with_capacity(nrs);
391
392        // Each resonance is 6 values on one line (or spanning lines).
393        // In ENDF format, LIST records pack 6 values per line.
394        let total_values = nrs * 6;
395        let values = parse_list_values(ctx.lines, ctx.pos, total_values)?;
396
397        for i in 0..nrs {
398            let base = i * 6;
399            resonances.push(Resonance {
400                energy: values[base],  // ER
401                j: values[base + 1],   // AJ
402                gn: values[base + 3],  // GN (neutron width)
403                gg: values[base + 4],  // GG (gamma width)
404                gfa: values[base + 5], // GF (fission width)
405                gfb: 0.0,              // Not used in BW
406                                       // Note: values[base+2] is GT (total width) — derived, not stored
407            });
408        }
409
410        l_groups.push(LGroup {
411            l: l_val,
412            awr: awr_l,
413            apl: 0.0, // Not in BW format
414            qx,
415            lrx,
416            resonances,
417        });
418    }
419
420    Ok(ResonanceRange {
421        energy_low: ctx.energy_low,
422        energy_high: ctx.energy_high,
423        resolved: true,
424        formalism,
425        target_spin,
426        scattering_radius,
427        naps: ctx.naps,
428        ap_table: ctx.ap_table.take(),
429        l_groups,
430        rml: None,
431        urr: None,
432        r_external: vec![],
433    })
434}
435
436/// Parse a Reich-Moore resolved resonance range.
437///
438/// ENDF-6 File 2, LRF=2:
439/// ENDF-6 File 2, LRF=3 (Reich-Moore):
440/// - CONT: SPI, AP, 0, 0, NLS, 0
441/// - For each L-value:
442///   - CONT: AWRI, APL, L, 0, 6*NRS, NRS
443///   - LIST: NRS resonances, each 6 values: ER, AJ, GN, GG, GFA, GFB
444///
445/// Reference: ENDF-6 Formats Manual Section 2.2.1.3
446/// Reference: SAMMY manual Section 2 (R-matrix theory)
447fn parse_reich_moore_range(
448    ctx: &mut RangeParseContext<'_>,
449) -> Result<ResonanceRange, EndfParseError> {
450    // CONT: SPI, AP, 0, 0, NLS, 0
451    // ENDF AP is in 10⁻¹² cm; convert to fm (×10).
452    let cont = parse_cont(ctx.lines, ctx.pos)?;
453    let target_spin = cont.c1;
454    let scattering_radius = cont.c2 * ENDF_RADIUS_TO_FM;
455    let nls = checked_count(cont.n1, "NLS")?; // number of L-values
456
457    let mut l_groups = Vec::with_capacity(nls);
458
459    for _ in 0..nls {
460        // CONT: AWRI, APL, L, 0, 6*NRS, NRS
461        let l_cont = parse_cont(ctx.lines, ctx.pos)?;
462        let awr_l = l_cont.c1;
463        let apl = l_cont.c2 * ENDF_RADIUS_TO_FM; // L-dependent scattering radius
464        // Validate L is non-negative (#123): negative L1 wraps to a huge u32.
465        if l_cont.l1 < 0 {
466            return Err(EndfParseError::UnsupportedFormat(format!(
467                "Reich-Moore range: negative L={}",
468                l_cont.l1
469            )));
470        }
471        let l_val = l_cont.l1 as u32;
472        let n1 = checked_count(l_cont.n1, "N1")?; // should be 6*NRS
473        let nrs = checked_count(l_cont.n2, "NRS")?; // number of resonances
474
475        // Validate N1 == 6*NRS (#123): a mismatch means the record is malformed
476        // and reading N1 values would over-/under-consume lines.
477        if n1 != 6 * nrs {
478            return Err(EndfParseError::UnsupportedFormat(format!(
479                "Reich-Moore range L={l_val}: N1={n1} != 6*NRS={} (NRS={nrs})",
480                6 * nrs
481            )));
482        }
483
484        let mut resonances = Vec::with_capacity(nrs);
485
486        // Each resonance is 6 values: ER, AJ, GN, GG, GFA, GFB
487        let total_values = nrs * 6;
488        let values = parse_list_values(ctx.lines, ctx.pos, total_values)?;
489
490        for i in 0..nrs {
491            let base = i * 6;
492            resonances.push(Resonance {
493                energy: values[base],  // ER (eV)
494                j: values[base + 1],   // AJ (total J)
495                gn: values[base + 2],  // GN (neutron width, eV)
496                gg: values[base + 3],  // GG (gamma width, eV)
497                gfa: values[base + 4], // GFA (fission width 1, eV)
498                gfb: values[base + 5], // GFB (fission width 2, eV)
499            });
500        }
501
502        l_groups.push(LGroup {
503            l: l_val,
504            awr: awr_l,
505            apl,
506            qx: 0.0, // Not used in Reich-Moore
507            lrx: 0,  // Not used in Reich-Moore
508            resonances,
509        });
510    }
511
512    Ok(ResonanceRange {
513        energy_low: ctx.energy_low,
514        energy_high: ctx.energy_high,
515        resolved: true,
516        formalism: ResonanceFormalism::ReichMoore,
517        target_spin,
518        scattering_radius,
519        naps: ctx.naps,
520        ap_table: ctx.ap_table.take(),
521        l_groups,
522        rml: None,
523        urr: None,
524        r_external: vec![],
525    })
526}
527
528/// Parse an R-Matrix Limited (LRF=7) resolved resonance range.
529///
530/// ## ENDF-6 Record Layout (File 2, MT=151, after range CONT + optional TAB1)
531///
532/// ```text
533/// CONT:  [SPI, AP, IFG, KRM, NJS, KRL]
534///        SPI = target spin, AP = global scattering radius (fm),
535///        NJS = number of spin groups (J,π)
536///
537/// LIST:  [0, 0, NPP, 0, 12*NPP, NPP]   ← particle pair definitions
538///        12 values per pair: [MA, MB, ZA, ZB, IA, IB, Q, PNT, SHF, MT, PA, PB]
539///
540/// For each spin group j = 1..NJS:
541///   LIST: [AJ, PJ, KBK, KPS, 6*(NCH+1), NCH+1]   ← header + channels
542///         First 6 values: header row [0, 0, 0, 0, 0, NCH]
543///         NCH × 6 values: [IPP, L, SCH, BND, APE, APT] per channel
544///
545///   LIST: [0, 0, 0, NRS, 6*NX, NX]                  ← resonance parameters
546///         Per ENDF-6 §2.2.1.6 and SAMMY mrml01.f:413-415, NRS is in L2
547///         (resonance count for this spin group) and NX is in N2 (number of
548///         packed 6-float ENDF rows = NRS · ceil(stride/6) where stride is
549///         NCH+1 for KRM=2 and NCH+2 for KRM=3); N1 = 6*NX.
550///         KRM=2: stride ≥ NCH+1; per resonance: [ER, γ_1, ..., γ_NCH, <padding>]
551///         KRM=3: stride ≥ NCH+2; per resonance: [ER, Γγ, Γ_1, ..., Γ_NCH, <padding>]
552/// ```
553///
554/// Reference: ENDF-6 Formats Manual §2.2.1.6; SAMMY rml/mrml01.f
555fn parse_rmatrix_limited_range(
556    ctx: &mut RangeParseContext<'_>,
557    awr: f64,
558) -> Result<ResonanceRange, EndfParseError> {
559    // CONT: [SPI, AP, IFG, KRM, NJS, KRL]
560    // ENDF AP is in 10⁻¹² cm; convert to fm (×10).
561    let cont = parse_cont(ctx.lines, ctx.pos)?;
562    let target_spin = cont.c1;
563    let scattering_radius = cont.c2 * ENDF_RADIUS_TO_FM;
564    // IFG (L1): radius unit flag.
565    //   IFG=0: AP, APE, APT are in 10⁻¹² cm — universal in ENDF/B-VIII.0.
566    //   IFG=1: radii are in units of ℏ/k (energy-dependent) — not supported here.
567    // SAMMY's WriteRrEndf.cpp always writes IFG=0 and its reader never checks it,
568    // confirming IFG=1 is not used in practice.
569    // Reference: ENDF-6 §2.2.1.6; SAMMY ndf/WriteRrEndf.cpp line 363.
570    let ifg = cont.l1;
571    if ifg != 0 {
572        return Err(EndfParseError::UnsupportedFormat(format!(
573            "LRF=7 IFG={ifg} (energy-dependent radii) is not supported (only IFG=0)"
574        )));
575    }
576    let krm = cont.l2 as u32; // R-matrix type: 2=standard, 3=Reich-Moore approx
577    // P2: Validate KRM at parse time so the physics code never sees an unknown type.
578    // KRM=0/1/4 are defined in the ENDF spec but not supported here.
579    // Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml01.f KRM field.
580    if krm != 2 && krm != 3 {
581        return Err(EndfParseError::UnsupportedFormat(format!(
582            "LRF=7 KRM={krm} is not supported (only KRM=2 and KRM=3)"
583        )));
584    }
585    let njs = checked_count(cont.n1, "NJS")?; // number of spin groups
586    // KRL (N2): kinematics flag.
587    //   KRL=0: non-relativistic kinematics — universal in ENDF/B-VIII.0.
588    //   KRL=1: relativistic kinematics — not supported here.
589    // SAMMY's WriteRrEndf.cpp always writes KRL=0.
590    // Reference: ENDF-6 §2.2.1.6; SAMMY ndf/WriteRrEndf.cpp line 366.
591    let krl = cont.n2;
592    if krl != 0 {
593        return Err(EndfParseError::UnsupportedFormat(format!(
594            "LRF=7 KRL={krl} (relativistic kinematics) is not supported (only KRL=0)"
595        )));
596    }
597
598    // LIST: [0, 0, NPP, 0, 12*NPP, NPP]  — particle pair definitions
599    // NPP is authoritative in L1; N2 is nominally equal but can encode a
600    // different count in some files (e.g. N2 = 2*NPP).  Always derive from L1.
601    // Reference: ENDF-6 Formats Manual §2.2.1.6 Table 2.1.
602    let pp_cont = parse_cont(ctx.lines, ctx.pos)?;
603    let npp = checked_count(pp_cont.l1, "NPP")?;
604    let pp_values = parse_list_values(ctx.lines, ctx.pos, npp * 12)?;
605
606    // Validate-and-narrow an ENDF integer-coded particle-pair flag.  ENDF integer
607    // fields are whole numbers, so a fractional or non-finite f64 indicates a
608    // malformed record and must not be silently truncated/saturated: PNT=1.7
609    // would narrow to 1 and PNT=NaN to 0, both bypassing the {0,1} range check
610    // below.  Applied to PNT and SHF — the two flags the physics branches on.
611    fn pp_int_flag(value: f64, field: &str, idx: usize) -> Result<i32, EndfParseError> {
612        if !value.is_finite() || value.fract() != 0.0 {
613            return Err(EndfParseError::UnsupportedFormat(format!(
614                "LRF=7 particle pair {idx}: {field}={value} is not a finite integer"
615            )));
616        }
617        Ok(value as i32)
618    }
619
620    let mut particle_pairs = Vec::with_capacity(npp);
621    for i in 0..npp {
622        let b = i * 12;
623        particle_pairs.push(ParticlePair {
624            ma: pp_values[b],
625            mb: pp_values[b + 1],
626            za: pp_values[b + 2],
627            zb: pp_values[b + 3],
628            ia: pp_values[b + 4],
629            ib: pp_values[b + 5],
630            q: pp_values[b + 6],
631            pnt: pp_int_flag(pp_values[b + 7], "PNT", i)?,
632            shf: pp_int_flag(pp_values[b + 8], "SHF", i)?,
633            mt: pp_values[b + 9] as u32,
634            pa: pp_values[b + 10],
635            pb: pp_values[b + 11],
636        });
637    }
638
639    for (i, pp) in particle_pairs.iter().enumerate() {
640        // PNT (Lpent) must be 0 or 1.  SAMMY's Check_Quantum (rml/mrml03.f:22)
641        // rejects Lpent ∉ {0,1} via Wrongi; PNT=2 ("ASSIGN") is defined in the
642        // ENDF-6 spec but neither SAMMY nor NEREIDS implements it.  Validate up
643        // front so the physics code never sees an unknown penetrability flag.
644        if pp.pnt != 0 && pp.pnt != 1 {
645            return Err(EndfParseError::UnsupportedFormat(format!(
646                "LRF=7 particle pair {i}: PNT={} is not supported (only PNT=0 \
647                 and PNT=1; SAMMY rejects Lpent outside {{0,1}})",
648                pp.pnt
649            )));
650        }
651        // A massless pair (photon/eliminated channel, MA=0) must carry PNT=0:
652        // SAMMY always assigns Lpent=0 to the photon channel, and the physics
653        // evaluator's penetrability branch (which divides by a reduced mass)
654        // is only entered for PNT=1.  Reject the inconsistent combination so a
655        // massless PNT=1 pair can never reach a divide-by-zero reduced mass.
656        if pp.ma < 0.5 && pp.pnt != 0 {
657            return Err(EndfParseError::UnsupportedFormat(format!(
658                "LRF=7 particle pair {i}: massless pair (MA={}) with PNT={} is \
659                 invalid; a photon/eliminated channel must have PNT=0",
660                pp.ma, pp.pnt
661            )));
662        }
663        // A PNT=1 pair drives the penetrability path, which forms the reduced
664        // mass μ = MA·MB/(MA+MB) (rmatrix_limited.rs).  Validate the reduced mass
665        // itself — computed exactly as the physics does — is finite and strictly
666        // positive, so the physics never sees a non-finite μ.  Checking MA/MB > 0
667        // alone is insufficient: pathological huge (but finite) masses can still
668        // overflow MA·MB to ∞.  This also covers the MA+MB = 0 / sign cases.
669        if pp.pnt == 1 {
670            let reduced_mass = pp.ma * pp.mb / (pp.ma + pp.mb);
671            if !(pp.ma.is_finite()
672                && pp.mb.is_finite()
673                && pp.ma > 0.0
674                && pp.mb > 0.0
675                && reduced_mass.is_finite()
676                && reduced_mass > 0.0)
677            {
678                return Err(EndfParseError::UnsupportedFormat(format!(
679                    "LRF=7 particle pair {i}: PNT=1 requires finite positive masses \
680                     yielding a finite reduced mass (MA={}, MB={})",
681                    pp.ma, pp.mb
682                )));
683            }
684        }
685        // Coulomb + SHF=1: closed-channel Coulomb shift at imaginary argument is
686        // unimplemented.  Reject at parse time rather than silently producing wrong
687        // dispersive terms near threshold.
688        // Reference: SAMMY rml/mrml07.f — Pghcou is only called for open channels.
689        if pp.za.abs() > 0.5 && pp.zb.abs() > 0.5 && pp.shf == 1 {
690            return Err(EndfParseError::UnsupportedFormat(format!(
691                "LRF=7 particle pair {i}: Coulomb channel (za={}, zb={}) with \
692                 SHF=1 is not supported; closed-channel Coulomb shift at \
693                 imaginary rho is not yet implemented",
694                pp.za, pp.zb
695            )));
696        }
697    }
698
699    // All particle-pair types are now fully supported (with the SHF=1 restriction above):
700    // - PNT 0/1: branched on pp.pnt in rmatrix_limited.rs (SAMMY Lpent); PNT∉{0,1} rejected above.
701    // - SHF 0/1: respected by the shf field in rmatrix_limited.rs.
702    // - Coulomb channels (pp.za > 0 && pp.zb > 0): routed through
703    //   nereids_physics::coulomb (Steed's CF1+CF2, SAMMY coulomb/mrml08.f90).
704    let mut spin_groups = Vec::with_capacity(njs);
705
706    for _ in 0..njs {
707        // LIST: [AJ, PJ, KBK, KPS, 6*(NCH+1), NCH+1]
708        // First 6*(NCH+1) values: header row [0,0,0,0,0,NCH] then NCH×6 channel defs.
709        let sg_cont = parse_cont(ctx.lines, ctx.pos)?;
710        let aj = sg_cont.c1;
711        let pj = sg_cont.c2; // explicit parity field; may be 0.0 when parity is in sign(AJ)
712        let kbk = sg_cont.l1; // background R-matrix flag
713        let kps = sg_cont.l2; // phase shift flag
714
715        // KBK: background R-matrix correction (R-external function on a
716        // subset of channels). Per the printed ENDF-6 §2.2.1.6 Tables 2.4/2.5
717        // KBK is described as a nonzero flag with NCH background records,
718        // while the reference reader OpenScale
719        // (external/openScale/repo/packages/ScaleUtils/EndfLib/endf/File2.cpp:444-524)
720        // treats KBK as a sparse record count, with each subrecord's L1 holding
721        // the 1-based channel index and L2 holding the LBK formalism flag
722        // (LBK ∈ {0=no payload, 1=two TAB1, 2=LIST(5), 3=LIST(3)}). The two
723        // conventions disagree on (a) the loop bound, (b) the per-subrecord
724        // control-field positions, and (c) the payload shape per LBK value.
725        //
726        // No ENDF/B-VIII.0 evaluation in the local cache has nonzero KBK or
727        // KPS to disambiguate, and the only nonzero example located on disk
728        // is OpenScale's synthetic F-19 R-external test fixture
729        // (Ampx/TestRunner/test/data/polident/f19_rext.endf), which follows
730        // the OpenScale convention. NEREIDS's previous layout matched neither
731        // convention. Until a policy decision is made (strict-manual vs.
732        // OpenScale-compat) and a real ENDF/B-VIII.0 evaluation with R-external
733        // is available to validate against, reject nonzero KBK explicitly so
734        // the parser cannot silently misalign the stream past this spin group.
735        //
736        // The reject runs immediately after reading the spin-group CONT —
737        // before parsing the (potentially large) channel and resonance LISTs —
738        // so that unsupported files fail fast without wasting allocation and
739        // parsing work on records that will be discarded.
740        if kbk != 0 {
741            let nch_plus_one_raw = sg_cont.n2;
742            return Err(EndfParseError::UnsupportedFormat(format!(
743                "LRF=7 KBK={kbk} != 0 (R-external background) for spin group with \
744                 NCH+1={nch_plus_one_raw}: \
745                 the ENDF-6 manual vs. OpenScale layout dispute is unresolved and NEREIDS \
746                 does not yet parse nonzero KBK. Use the SAMMY .par/.inp converter \
747                 (sammy_to_resonance_data_multi) if R-external is required."
748            )));
749        }
750
751        // KPS: tabulated penetrability/phase-shift override per channel.
752        // Same documentation-vs-implementation dispute as KBK above
753        // (OpenScale File2.cpp:439-441 throws "kps > 0 for lrf=7 not yet
754        // supported" and never reads the subrecords). NEREIDS rejects nonzero
755        // KPS for the same reason: no validated reference layout, no real
756        // evaluation to test against.
757        if kps != 0 {
758            let nch_plus_one_raw = sg_cont.n2;
759            return Err(EndfParseError::UnsupportedFormat(format!(
760                "LRF=7 KPS={kps} != 0 (tabulated penetrability/phase-shift override) \
761                 for spin group with NCH+1={nch_plus_one_raw}: \
762                 NEREIDS does not yet parse nonzero KPS. \
763                 OpenScale itself rejects this case (\"kps > 0 for lrf=7 not yet supported\")."
764            )));
765        }
766
767        // AJ encodes both the spin and, in some evaluations, the parity.
768        // ENDF/B-VIII.0 evaluations such as W-184 use negative AJ for odd-parity
769        // spin groups (e.g., AJ=-0.5, AJ=-1.5) and set PJ=0.
770        // Statistical weight formula (2J+1)/... requires J > 0; negative J yields
771        // zero or negative weights and drives non-physical cross-sections.
772        // Fix: J = |AJ|; parity from sign(AJ) when PJ is absent (PJ=0).
773        // Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml01.f Scan_File_2.
774        let j = aj.abs();
775        let parity = if pj != 0.0 {
776            pj.signum()
777        } else if aj < 0.0 {
778            -1.0
779        } else {
780            1.0
781        };
782        let npl = checked_count(sg_cont.n1, "NPL")?; // 6*(NCH+1)
783        let nch_plus_one = checked_count(sg_cont.n2, "NCH+1")?; // NCH+1
784
785        // NCH+1 <= 1 would imply zero physical channels (NCH = 0), which is
786        // meaningless for a resonance range — every spin group must have at
787        // least one channel.
788        // Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml01.f.
789        if nch_plus_one <= 1 {
790            return Err(EndfParseError::UnsupportedFormat(format!(
791                "RML spin-group LIST: NCH+1 must be >= 2, got NCH+1={nch_plus_one}"
792            )));
793        }
794        let nch = nch_plus_one - 1;
795
796        let sg_values = parse_list_values(ctx.lines, ctx.pos, npl)?;
797
798        // C3: Validate that the LIST record carries at least 6*(NCH+1) values.
799        // NCH is derived from N2 in the LIST header (N2 = NCH+1); the first data row
800        // is a dummy/header row of zeros that ENDF evaluators may fill arbitrarily.
801        // SAMMY (mrml01.f Scan_File_2/ENDF123) reads NCH from N2 and ignores row[5].
802        // Reference: ENDF-6 §2.2.1.6 Table 2.3; SAMMY rml/mrml01.f lines 104-107.
803        let expected_npl = 6 * (nch + 1);
804        if npl < expected_npl {
805            return Err(EndfParseError::UnsupportedFormat(format!(
806                "LRF=7 spin-group LIST: NPL={npl} < 6*(NCH+1)={expected_npl}"
807            )));
808        }
809
810        // First 6 values are the dummy header row (zeros); subsequent NCH×6 values
811        // are channel definitions [IPP, L, SCH, BND, APE, APT] per channel.
812        let npp = particle_pairs.len();
813        let mut channels = Vec::with_capacity(nch);
814        for c in 0..nch {
815            let b = 6 + c * 6; // skip the 6-value header row
816            // C2: IPP is 1-based in ENDF; validate range before converting.
817            let ipp_raw = sg_values[b] as usize;
818            if ipp_raw == 0 || ipp_raw > npp {
819                return Err(EndfParseError::UnsupportedFormat(format!(
820                    "LRF=7 spin-group channel IPP={ipp_raw} is out of range 1..={npp}"
821                )));
822            }
823            // Photon channels (MA < 0.5, PNT=0) are stored as regular channels.
824            // The physics code sets P_c=1, S_c=B_c, φ_c=0 for PNT=0 channels
825            // (rmatrix_limited.rs; SAMMY rml/mrml07.f:118-122 Ymat(2)-=1) and
826            // classifies them as capture channels via pp.mt == 102.  Their reduced width amplitudes
827            // appear at the corresponding column position in the resonance rows,
828            // exactly like any other channel.  Reference: ENDF-6 §2.2.1.6; SAMMY
829            // rml/mrml01.f (Ippx test, mrml07.f P=1 convention for massless).
830            channels.push(RmlChannel {
831                particle_pair_idx: ipp_raw - 1, // convert 1-based ENDF index to 0-based
832                l: sg_values[b + 1] as u32,     // L
833                channel_spin: sg_values[b + 2], // SCH
834                boundary: sg_values[b + 3],     // BND
835                effective_radius: sg_values[b + 4] * ENDF_RADIUS_TO_FM, // APE
836                true_radius: sg_values[b + 5] * ENDF_RADIUS_TO_FM, // APT
837            });
838        }
839
840        // Apply global scattering radius for channels where APE/APT == 0
841        for ch in &mut channels {
842            if ch.effective_radius == 0.0 {
843                ch.effective_radius = scattering_radius;
844            }
845            if ch.true_radius == 0.0 {
846                ch.true_radius = scattering_radius;
847            }
848        }
849
850        // LIST: [0, 0, 0, NRS, 6*NX, NX]  — resonance parameters.
851        //
852        // ENDF-6 §2.2.1.6 fixes the resonance LIST control fields as
853        // [C1=0, C2=0, L1=0, L2=NRS, N1=6*NX, N2=NX]:
854        //   NRS lives in L2 (the resonance count for this spin group).
855        //   NX  lives in N2 (number of packed 6-float ENDF data rows =
856        //       NRS · ceil(stride/6) where stride is NCH+1 for KRM=2 and
857        //       NCH+2 for KRM=3), and N1 must equal 6*NX.
858        //
859        // For spin groups where each resonance fits in one packed row
860        // (NCH+1 ≤ 6 for KRM=2, NCH+2 ≤ 6 for KRM=3) NX == NRS and the
861        // distinction is invisible; for larger NCH (e.g. F-19 spin groups
862        // with NCH≥5) NX > NRS and reading NRS from N2 over-counts the
863        // resonances and trips the stride guard below with a misleading
864        // "stride too small" error.
865        //
866        // SAMMY reads NRS via `FORMAT (33X, I11)` which skips C1+C2+L1
867        // (3 × 11 chars) and reads L2 (mrml01.f:413-415, also :116-119
868        // for the scan pass). OpenScale reads `list.getL2()` and writes
869        // `list.setL2(nres) / setN2(nx)` (File2.cpp:415, :686-697).
870        //
871        // For KRM=3 (e.g. W-184 ENDF/B-VIII.0), evaluators pad each resonance row
872        // to a fixed 6 values per ENDF line, so NPL/NRS = 6 even when NCH=1.
873        // Using hardcoded nch+1 drifts the offset and misreads zeros as energies.
874        // Fix: derive stride directly from NPL/NRS; read only NCH widths per row.
875        let res_cont = parse_cont(ctx.lines, ctx.pos)?;
876        let nrs = checked_count(res_cont.l2, "NRS")?;
877        let nx = checked_count(res_cont.n2, "NX")?;
878        let res_npl = checked_count(res_cont.n1, "NPL")?;
879        if res_npl != 6 * nx {
880            return Err(EndfParseError::UnsupportedFormat(format!(
881                "LRF=7 resonance LIST: N1 ({res_npl}) != 6 * N2 ({}); ENDF-6 §2.2.1.6 \
882                 requires NPL = 6*NX for the packed-row layout",
883                6 * nx
884            )));
885        }
886        // Per ENDF-6 §2.2.1.6, NX is the per-spin-group packed-row count:
887        //     NX = NRS · ceil(per_resonance_floats / 6)
888        // where the per-resonance float count is layout-dependent:
889        //     KRM=2: per_resonance = NCH+1  (ER + NCH reduced widths γ_c)
890        //     KRM=3: per_resonance = NCH+2  (ER + Γγ + NCH partial widths Γ_c)
891        // SAMMY rml/mrml01.f ENDF123 confirms the KRM=3 layout reads Gamgam
892        // at position 1 and (Gamma,I=1,Ichan) at positions 2..NCH+1.
893        // Because the per-resonance row count is constant within a spin
894        // group, NX is always an integer multiple of NRS. A non-zero NRS
895        // with NX not divisible by NRS would yield a fractional stride
896        // (`6 * NX / NRS` non-integer) and mis-align resonance reads.
897        // Reject up-front rather than rely on the downstream
898        // `res_npl % nrs != 0` check, which is a weaker invariant.
899        if nrs > 0 && nx % nrs != 0 {
900            return Err(EndfParseError::UnsupportedFormat(format!(
901                "LRF=7 resonance LIST: N2/NX ({nx}) is not a multiple of L2/NRS ({nrs}); \
902                 ENDF-6 §2.2.1.6 requires NX = NRS * ceil(stride/6) where stride is \
903                 NCH+1 for KRM=2 and NCH+2 for KRM=3"
904            )));
905        }
906        // Canonical empty spin group per ENDF-6 §2.2.1.6 and OpenScale's
907        // writer at File2.cpp:683-697:
908        //   list.setL2(spin->getNres());        // L2 = NRS
909        //   ...
910        //   // nx must be at least 1, even if nres=0
911        //   if (spin->getNres() == 0)
912        //       nx = 1;
913        //   list.setN1(6 * nx);                  // N1 = 6
914        //   list.setN2(nx);                      // N2 = 1
915        // The LIST body for the empty spin group is a single 6-float zero
916        // filler row. Reject any NRS=0 record that does not carry NX=1
917        // (NX=0 is malformed by OpenScale; NX>1 would imply phantom rows
918        // with no resonance count to anchor them).
919        if nrs == 0 && nx != 1 {
920            return Err(EndfParseError::UnsupportedFormat(format!(
921                "LRF=7 resonance LIST: NRS=0 requires NX=1 (single zero-filler row \
922                 per ENDF-6 §2.2.1.6 + OpenScale File2.cpp:683-697); got NX={nx}"
923            )));
924        }
925        let res_values = parse_list_values(ctx.lines, ctx.pos, res_npl)?;
926
927        // C4: Validate stride before use — NPL must divide evenly by NRS, and each row
928        // must be at least min_stride values wide.
929        //
930        // KRM=2: per-resonance layout is [ER, Γ_1, ..., Γ_NCH, <padding>]
931        //        → min_stride = NCH+1 (energy + NCH reduced width amplitudes)
932        // KRM=3: per-resonance layout is [ER, Γγ, Γ_1, ..., Γ_NCH, <padding>]
933        //        → min_stride = NCH+2 (energy + Gamgam + NCH partial widths)
934        //
935        // Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml01.f ENDF123 subroutine —
936        //   reads Gamgam at position 1 (immediately after ER), then
937        //   (Gamma,I=1,Ichan) at positions 2..NCH+1.
938        let min_stride = if krm == 3 { nch + 2 } else { nch + 1 };
939        let stride = if nrs == 0 {
940            min_stride // no resonances; stride unused
941        } else {
942            if res_npl % nrs != 0 {
943                return Err(EndfParseError::UnsupportedFormat(format!(
944                    "LRF=7 resonance block NPL={res_npl} is not divisible by NRS={nrs}"
945                )));
946            }
947            let s = res_npl / nrs;
948            if s < min_stride {
949                return Err(EndfParseError::UnsupportedFormat(format!(
950                    "LRF=7 resonance stride={s} < {}={min_stride} \
951                     (KRM={krm}, NPL={res_npl}, NRS={nrs})",
952                    if krm == 3 { "NCH+2" } else { "NCH+1" }
953                )));
954            }
955            s
956        };
957        let mut resonances = Vec::with_capacity(nrs);
958        for r in 0..nrs {
959            let b = r * stride;
960            // Parse resonance row according to KRM column order.
961            //
962            // KRM=2: [ER, γ_1, ..., γ_NCH, <padding>]
963            //   widths (reduced amplitudes γ) start at b+1.
964            //   No capture width column; gamma_gamma = 0.
965            //
966            // KRM=3: [ER, Γγ, Γ_1, ..., Γ_NCH, <padding>]
967            //   Gamgam (radiation width, eV) is at b+1.
968            //   Partial widths Γ_c start at b+2.
969            //   Gamgam forms complex pole energies: Ẽ_n = E_n - i·Γγ/2.
970            //
971            // Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml01.f ENDF123 subroutine.
972            //
973            // Bounds safety: stride ≥ min_stride (verified above), b = r·stride,
974            // and r < nrs, so b + stride ≤ res_npl = res_values.len().
975            // For KRM=3: b+2+nch ≤ b+min_stride ≤ b+stride; guaranteed in bounds.
976            // For KRM=2: b+1+nch ≤ b+min_stride ≤ b+stride; guaranteed in bounds.
977            // Explicit error checks below make the safety locally verifiable and
978            // guard against future changes that might weaken the stride invariant.
979            let (widths, gamma_gamma) = if krm == 3 {
980                let need = b + 2 + nch;
981                if need > res_values.len() {
982                    return Err(EndfParseError::UnsupportedFormat(format!(
983                        "LRF=7 KRM=3 resonance row {r}: need {need} values, \
984                         have {} (stride={stride}, NCH={nch})",
985                        res_values.len()
986                    )));
987                }
988                let gamma_gamma = res_values[b + 1]; // Gamgam at position 1
989                let widths = res_values[b + 2..b + 2 + nch].to_vec(); // Γ_c at positions 2..NCH+1
990                (widths, gamma_gamma)
991            } else {
992                // KRM=2: widths immediately follow ER; no capture-width column.
993                let need = b + 1 + nch;
994                if need > res_values.len() {
995                    return Err(EndfParseError::UnsupportedFormat(format!(
996                        "LRF=7 KRM=2 resonance row {r}: need {need} values, \
997                         have {} (stride={stride}, NCH={nch})",
998                        res_values.len()
999                    )));
1000                }
1001                (res_values[b + 1..b + 1 + nch].to_vec(), 0.0)
1002            };
1003            resonances.push(RmlResonance {
1004                energy: res_values[b],
1005                widths,
1006                gamma_gamma,
1007            });
1008        }
1009
1010        spin_groups.push(SpinGroup {
1011            j,
1012            parity,
1013            channels,
1014            resonances,
1015            // Nonzero KBK/KPS are rejected at the top of this loop iteration
1016            // (immediately after the spin-group CONT is read), so any spin
1017            // group that reaches this point has no background correction.
1018            has_background_correction: false,
1019        });
1020    }
1021
1022    let rml = RmlData {
1023        target_spin,
1024        awr,
1025        scattering_radius,
1026        krm,
1027        particle_pairs,
1028        spin_groups,
1029    };
1030
1031    Ok(ResonanceRange {
1032        energy_low: ctx.energy_low,
1033        energy_high: ctx.energy_high,
1034        resolved: true,
1035        formalism: ResonanceFormalism::RMatrixLimited,
1036        target_spin,
1037        scattering_radius,
1038        naps: ctx.naps,
1039        ap_table: ctx.ap_table.take(),
1040        l_groups: Vec::new(),
1041        rml: Some(Box::new(rml)),
1042        urr: None,
1043        r_external: vec![],
1044    })
1045}
1046
1047/// Maximum sane ENDF count value.
1048///
1049/// ENDF files in practice never contain more than ~100k resonances per section.
1050/// Accepting `i32::MAX` would cause enormous allocations (gigabytes) on
1051/// malformed files.  This cap is generous enough for any real evaluation while
1052/// protecting against allocation bombs.
1053const MAX_ENDF_COUNT: i32 = 1_000_000;
1054
1055/// Validate that an ENDF integer count is non-negative and return as `usize`.
1056///
1057/// Malformed records can contain negative counts which, if cast directly to
1058/// `usize`, wrap to huge values and cause OOM panics in `Vec::with_capacity`
1059/// or `parse_list_values`.
1060fn checked_count(value: i32, label: &str) -> Result<usize, EndfParseError> {
1061    if value < 0 {
1062        return Err(EndfParseError::UnsupportedFormat(format!(
1063            "Negative ENDF count: {label}={value}"
1064        )));
1065    }
1066    if value > MAX_ENDF_COUNT {
1067        return Err(EndfParseError::UnsupportedFormat(format!(
1068            "ENDF count too large: {label}={value} (maximum {MAX_ENDF_COUNT})"
1069        )));
1070    }
1071    Ok(value as usize)
1072}
1073
1074/// Skip an unsupported URR body **with LFW=0** layout.
1075///
1076/// Called when LRU=2 has an LRF value other than 1 or 2 so that the records
1077/// for this range are consumed and subsequent ranges can still be parsed.
1078///
1079/// Structure consumed (ENDF-6 §2.2.2, LFW=0):
1080/// ```text
1081/// CONT: SPI, AP, 0, 0, NLS, 0
1082/// For each L (NLS times):
1083///   CONT: AWRI, 0, L, 0, N1, N2
1084///   if N2 > 0  -> LRF=1 style: one LIST record of N1 values
1085///   if N2 == 0 -> LRF=2 style: N1 J-sub-blocks, each = CONT + LIST(N1_j values)
1086/// ```
1087fn skip_urr_body(lines: &[&str], pos: &mut usize) -> Result<(), EndfParseError> {
1088    // CONT: SPI, AP, 0, 0, NLS, 0
1089    let header = parse_cont(lines, pos)?;
1090    let nls = checked_count(header.n1, "NLS")?;
1091
1092    for _ in 0..nls {
1093        // L CONT: AWRI, 0, L, 0, N1, N2
1094        let l_cont = parse_cont(lines, pos)?;
1095        let n1 = checked_count(l_cont.n1, "N1")?;
1096        let n2 = checked_count(l_cont.n2, "N2")?;
1097
1098        if n2 > 0 {
1099            // LRF=1 style: N2=NJS, N1=6*NJS — single LIST record.
1100            parse_list_values(lines, pos, n1)?;
1101        } else {
1102            // LRF=2 style: N1=NJS, N2=0 — N1 J-sub-blocks, each with their
1103            // own CONT (carrying 6*(NE+1) in N1) followed by a LIST record.
1104            for _ in 0..n1 {
1105                let j_cont = parse_cont(lines, pos)?;
1106                let jn1 = checked_count(j_cont.n1, "N1")?;
1107                parse_list_values(lines, pos, jn1)?;
1108            }
1109        }
1110    }
1111    Ok(())
1112}
1113
1114// ---------------------------------------------------------------------------
1115// Low-level ENDF line parsing helpers
1116// ---------------------------------------------------------------------------
1117
1118/// Parsed CONT (control) record with 2 floats, 4 integers.
1119struct ContRecord {
1120    c1: f64,
1121    c2: f64,
1122    l1: i32,
1123    l2: i32,
1124    n1: i32,
1125    n2: i32,
1126}
1127
1128/// Parse a CONT record from the current line.
1129fn parse_cont(lines: &[&str], pos: &mut usize) -> Result<ContRecord, EndfParseError> {
1130    if *pos >= lines.len() {
1131        return Err(EndfParseError::UnexpectedEof(
1132            "Expected CONT record but reached end of data".to_string(),
1133        ));
1134    }
1135    let line = lines[*pos];
1136    *pos += 1;
1137
1138    Ok(ContRecord {
1139        c1: parse_endf_float(line, 0)?,
1140        c2: parse_endf_float(line, 1)?,
1141        l1: parse_endf_int(line, 2)?,
1142        l2: parse_endf_int(line, 3)?,
1143        n1: parse_endf_int(line, 4)?,
1144        n2: parse_endf_int(line, 5)?,
1145    })
1146}
1147
1148/// Parse a LIST of floating-point values spanning multiple lines.
1149///
1150/// ENDF packs 6 values per line. We read ceil(n/6) lines.
1151fn parse_list_values(
1152    lines: &[&str],
1153    pos: &mut usize,
1154    n_values: usize,
1155) -> Result<Vec<f64>, EndfParseError> {
1156    let mut values = Vec::with_capacity(n_values);
1157    let n_lines = n_values.div_ceil(6);
1158
1159    for _ in 0..n_lines {
1160        if *pos >= lines.len() {
1161            return Err(EndfParseError::UnexpectedEof(
1162                "Expected LIST data but reached end".to_string(),
1163            ));
1164        }
1165        let line = lines[*pos];
1166        *pos += 1;
1167
1168        let remaining = n_values - values.len();
1169        let fields_on_line = remaining.min(6);
1170
1171        for field in 0..fields_on_line {
1172            values.push(parse_endf_float(line, field)?);
1173        }
1174    }
1175
1176    Ok(values)
1177}
1178
1179/// Parse a floating-point value from an 11-character ENDF field.
1180///
1181/// ENDF uses Fortran-style floats that may omit 'E', e.g.:
1182/// - " 1.234567+2" means 1.234567e+2
1183/// - "-3.456789-1" means -3.456789e-1
1184/// - " 0.000000+0" means 0.0
1185fn parse_endf_float(line: &str, field_index: usize) -> Result<f64, EndfParseError> {
1186    let start = field_index * 11;
1187    let end = start + 11;
1188
1189    if line.len() < end {
1190        // Short line — treat as zero.
1191        return Ok(0.0);
1192    }
1193
1194    let field = &line[start..end];
1195    let trimmed = field.trim();
1196
1197    if trimmed.is_empty() {
1198        return Ok(0.0);
1199    }
1200
1201    // Try standard Rust float parsing first.
1202    if let Ok(v) = trimmed.parse::<f64>() {
1203        return Ok(v);
1204    }
1205
1206    // Handle Fortran-style: "1.234567+2" or "-3.456789-1"
1207    // Look for +/- that is NOT the first character and NOT preceded by 'e'/'E'/'d'/'D'.
1208    let bytes = trimmed.as_bytes();
1209    for i in 1..bytes.len() {
1210        if (bytes[i] == b'+' || bytes[i] == b'-')
1211            && bytes[i - 1] != b'e'
1212            && bytes[i - 1] != b'E'
1213            && bytes[i - 1] != b'd'
1214            && bytes[i - 1] != b'D'
1215            && bytes[i - 1] != b'+'
1216            && bytes[i - 1] != b'-'
1217        {
1218            let mantissa = &trimmed[..i];
1219            let exp_slice = &trimmed[i..];
1220            // Strip spaces from the exponent only when present (some ENDF files
1221            // write "+ 4" not "+4").  Avoid allocation on the common path.
1222            let with_e = if exp_slice.contains(' ') {
1223                let exponent: String = exp_slice.chars().filter(|c| !c.is_whitespace()).collect();
1224                format!("{}E{}", mantissa, exponent)
1225            } else {
1226                format!("{}E{}", mantissa, exp_slice)
1227            };
1228            if let Ok(v) = with_e.parse::<f64>() {
1229                return Ok(v);
1230            }
1231        }
1232    }
1233
1234    Err(EndfParseError::InvalidNumber(format!(
1235        "Cannot parse ENDF float: '{}'",
1236        field
1237    )))
1238}
1239
1240/// Parse an integer from an 11-character ENDF field.
1241fn parse_endf_int(line: &str, field_index: usize) -> Result<i32, EndfParseError> {
1242    let start = field_index * 11;
1243    let end = start + 11;
1244
1245    if line.len() < end {
1246        return Ok(0);
1247    }
1248
1249    let field = &line[start..end];
1250    let trimmed = field.trim();
1251
1252    if trimmed.is_empty() {
1253        return Ok(0);
1254    }
1255
1256    // ENDF integers may have a decimal point (e.g., "1.000000+0" for 1).
1257    // Try integer parse first, then float-with-integral-value.
1258    if let Ok(v) = trimmed.parse::<i32>() {
1259        return Ok(v);
1260    }
1261
1262    // Try parsing as float, but reject non-integral values (e.g., "1.9e+0")
1263    // rather than silently truncating.  Use the same ε=1e-6 tolerance as
1264    // `parse_tab1`'s NBT/INT validation so that integer fields stored as
1265    // ENDF floats ("1.000000+0") still round-trip, but a malformed
1266    // "1.900000+0" is surfaced as an InvalidNumber rather than parsed as 1.
1267    if let Ok(v) = parse_endf_float(line, field_index) {
1268        if (v - v.round()).abs() > 1e-6 {
1269            return Err(EndfParseError::InvalidNumber(format!(
1270                "Non-integral value in ENDF int field: '{}' (={})",
1271                field, v
1272            )));
1273        }
1274        return Ok(v.round() as i32);
1275    }
1276
1277    Err(EndfParseError::InvalidNumber(format!(
1278        "Cannot parse ENDF int: '{}'",
1279        field
1280    )))
1281}
1282
1283/// Parse a URR range with LFW=1, LRF=1 (energy-dependent fission widths,
1284/// single-level BW).
1285///
1286/// ## Record layout (ENDF-6 §2.2.2.1 "Case B")
1287/// ```text
1288/// CONT: SPI, AP, LSSF, 0, NE, NLS
1289/// LIST: NE energy values (shared fission width grid)
1290/// For each L:
1291///   CONT: AWRI, 0, L, 0, NJS, 0
1292///   For each J:
1293///     LIST control: 0.0, 0.0, L, MUF, NE+6, 0
1294///     LIST body:    [D, AJ, AMUN, GNO, GG, 0] + NE fission widths
1295/// ```
1296///
1297/// Each per-J record is a full ENDF LIST: a control line carrying the data
1298/// count `N1 = NE+6` and the fission degrees-of-freedom `MUF` (the L2 field),
1299/// followed by `NE+6` data values.  The control line MUST be consumed before
1300/// the body — omitting it misaligns the line stream by one record per J-group.
1301///
1302/// Other widths (D, GNO, GG) are energy-independent (single values).
1303/// Only fission widths (GF) are tabulated at the shared energy grid.
1304///
1305/// Reference: ENDF-6 §2.2.2.1 Case B; SCALE/openScale `File2.cpp`
1306/// (`lfw==1 && lrf==1` branch, per-J `list.readData`/`cont.readData`) and
1307/// `File2Unres.f90` (read loop, per-J `ControlEndf_read` + `ListEndf_read`;
1308/// `File2Unres_getMuf` reads MUF from the control L2 field, `getNeJ` reads
1309/// `N1 = NE+6`).
1310fn parse_urr_lfw1_lrf1(ctx: &mut RangeParseContext<'_>) -> Result<ResonanceRange, EndfParseError> {
1311    // CONT: SPI, AP, LSSF, 0, NE, NLS
1312    let header = parse_cont(ctx.lines, ctx.pos)?;
1313    let spi = header.c1;
1314    let ap = header.c2 * ENDF_RADIUS_TO_FM;
1315    let ne = checked_count(header.n1, "NE")?;
1316    let nls = checked_count(header.n2, "NLS")?;
1317
1318    // LIST: NE energy values (shared fission width grid)
1319    let fission_energies = parse_list_values(ctx.lines, ctx.pos, ne)?;
1320
1321    let mut l_groups = Vec::with_capacity(nls);
1322
1323    for _ in 0..nls {
1324        // CONT: AWRI, 0, L, 0, NJS, 0
1325        let l_cont = parse_cont(ctx.lines, ctx.pos)?;
1326        let awri = l_cont.c1;
1327        let l = l_cont.l1 as u32;
1328        let njs = checked_count(l_cont.n1, "NJS")?;
1329
1330        let mut j_groups = Vec::with_capacity(njs);
1331
1332        for _ in 0..njs {
1333            // Per-J LIST control: 0.0, 0.0, L, MUF, NE+6, 0
1334            // MUF (fission degrees of freedom) is the L2 field; the data
1335            // count N1 must equal NE+6.  Consuming this control record keeps
1336            // the line stream aligned — the body follows on the next lines.
1337            let j_cont = parse_cont(ctx.lines, ctx.pos)?;
1338            let muf = j_cont.l2;
1339            let n1 = checked_count(j_cont.n1, "N1")?;
1340            // SCALE validates this exact relation (File2.cpp: N1-6 == NE).
1341            if n1 != ne + 6 {
1342                return Err(EndfParseError::UnsupportedFormat(format!(
1343                    "URR LFW=1/LRF=1: per-J N1={n1} ≠ NE+6={} (NE={ne})",
1344                    ne + 6
1345                )));
1346            }
1347
1348            // LIST body: [D, AJ, AMUN, GNO, GG, 0] + NE fission widths
1349            let values = parse_list_values(ctx.lines, ctx.pos, ne + 6)?;
1350
1351            let d = values[0];
1352            let aj = values[1];
1353            let amun = values[2];
1354            let gno = values[3];
1355            let gg = values[4];
1356            // values[5] = 0 (unused)
1357
1358            // Fission widths from the shared energy grid
1359            let gf: Vec<f64> = values[6..6 + ne].to_vec();
1360
1361            j_groups.push(UrrJGroup {
1362                j: aj,
1363                amun,
1364                // Case B carries the fission degrees of freedom MUF in the
1365                // per-J control record's L2 field; store it as AMUF.
1366                amuf: muf as f64,
1367                energies: fission_energies.clone(),
1368                d: vec![d],
1369                gx: vec![0.0],
1370                gn: vec![gno],
1371                gg: vec![gg],
1372                gf,
1373                int_code: 2, // Default lin-lin for fission width interpolation
1374            });
1375        }
1376
1377        l_groups.push(UrrLGroup { l, awri, j_groups });
1378    }
1379
1380    Ok(ResonanceRange {
1381        energy_low: ctx.energy_low,
1382        energy_high: ctx.energy_high,
1383        resolved: false,
1384        formalism: ResonanceFormalism::Unresolved,
1385        target_spin: spi,
1386        scattering_radius: ap,
1387        naps: ctx.naps,
1388        ap_table: ctx.ap_table.take(),
1389        l_groups: Vec::new(),
1390        rml: None,
1391        urr: Some(Box::new(UrrData {
1392            lrf: 1,
1393            spi,
1394            ap,
1395            e_low: ctx.energy_low,
1396            e_high: ctx.energy_high,
1397            l_groups,
1398        })),
1399        r_external: vec![],
1400    })
1401}
1402
1403/// Parse an Unresolved Resonance Region (LRU=2) range.
1404///
1405/// Handles two routes:
1406/// * LFW=0 (LRF=1 energy-independent or LRF=2 tabulated widths) — the
1407///   standard URR case.
1408/// * LFW=1 / LRF=2 — the LIST record layout is byte-identical to
1409///   LFW=0/LRF=2 (the LFW=1 fission-width grid is embedded in each
1410///   per-J LIST row rather than a separate shared-grid block), so the
1411///   caller routes that combination here.
1412///
1413/// LFW=1 / LRF=1 (energy-dependent fission widths with the shared-grid
1414/// layout) is handled separately by `parse_urr_lfw1_lrf1`.
1415///
1416/// Reference: ENDF-6 Formats Manual §2.2.2
1417fn parse_urr_range(
1418    ctx: &mut RangeParseContext<'_>,
1419    lrf: i32,
1420) -> Result<ResonanceRange, EndfParseError> {
1421    use crate::resonance::{UrrData, UrrJGroup, UrrLGroup};
1422
1423    // See function-level rustdoc for the LFW/LRF routing rules.
1424
1425    // CONT: SPI, AP, 0, 0, NLS, 0
1426    // ENDF AP is in 10⁻¹² cm; convert to fm (×10).
1427    let spi_cont = parse_cont(ctx.lines, ctx.pos)?;
1428    let spi = spi_cont.c1;
1429    let ap = spi_cont.c2 * ENDF_RADIUS_TO_FM; // scattering radius (fm)
1430    let nls = checked_count(spi_cont.n1, "NLS")?;
1431
1432    let mut l_groups = Vec::with_capacity(nls);
1433
1434    if lrf == 1 {
1435        // LRF=1: energy-independent widths, one LIST block per L covering all J.
1436        for _ in 0..nls {
1437            // CONT: AWRI, 0, L, 0, N1=6*NJS, N2=NJS
1438            let l_cont = parse_cont(ctx.lines, ctx.pos)?;
1439            let awri = l_cont.c1;
1440            if l_cont.l1 < 0 {
1441                return Err(EndfParseError::UnsupportedFormat(format!(
1442                    "URR LRF=1: negative L={}",
1443                    l_cont.l1
1444                )));
1445            }
1446            let l = l_cont.l1 as u32;
1447            let n1 = checked_count(l_cont.n1, "N1")?; // 6*NJS
1448            let njs = checked_count(l_cont.n2, "NJS")?;
1449
1450            if njs == 0 || n1 != 6 * njs {
1451                return Err(EndfParseError::UnsupportedFormat(format!(
1452                    "URR LRF=1 L={l}: N1={n1} ≠ 6×NJS={} (NJS={njs})",
1453                    6 * njs
1454                )));
1455            }
1456
1457            let values = parse_list_values(ctx.lines, ctx.pos, n1)?;
1458
1459            let mut j_groups = Vec::with_capacity(njs);
1460            for j_idx in 0..njs {
1461                let base = j_idx * 6;
1462                // [D, AJ, AMUN, GNO, GG, GF]
1463                j_groups.push(UrrJGroup {
1464                    j: values[base + 1],        // AJ
1465                    amun: values[base + 2],     // AMUN (neutron DOF)
1466                    amuf: 0.0,                  // LRF=1 format does not carry AMUF
1467                    energies: vec![],           // Energy-independent
1468                    d: vec![values[base]],      // D (level spacing, eV)
1469                    gx: vec![0.0],              // No competitive width in LRF=1
1470                    gn: vec![values[base + 3]], // GNO (reduced neutron width, eV)
1471                    gg: vec![values[base + 4]], // GG (gamma width, eV)
1472                    gf: vec![values[base + 5]], // GF (fission width, eV)
1473                    int_code: 2,                // LRF=1 has no table; default lin-lin
1474                });
1475            }
1476
1477            l_groups.push(UrrLGroup { l, awri, j_groups });
1478        }
1479    } else {
1480        // LRF=2: energy-dependent width tables, one LIST per (L, J).
1481        for _l_idx in 0..nls {
1482            // CONT: AWRI, 0, L, 0, NJS, 0
1483            let l_cont = parse_cont(ctx.lines, ctx.pos)?;
1484            let awri = l_cont.c1;
1485            if l_cont.l1 < 0 {
1486                return Err(EndfParseError::UnsupportedFormat(format!(
1487                    "URR LRF=2: negative L={}",
1488                    l_cont.l1
1489                )));
1490            }
1491            let l = l_cont.l1 as u32;
1492            let njs = checked_count(l_cont.n1, "NJS")?; // N1 = NJS for LRF=2
1493
1494            // Zero NJS means no J-groups for this L-value, which is malformed
1495            // (ENDF §2.2.2.2 requires at least one J-group per L-group).
1496            // Consistent with the LRF=1 path which also rejects NJS=0.
1497            if njs == 0 {
1498                return Err(EndfParseError::UnsupportedFormat(format!(
1499                    "URR LRF=2 L={l}: NJS=0 (at least one J-group required)"
1500                )));
1501            }
1502
1503            let mut j_groups = Vec::with_capacity(njs);
1504            for _j_idx in 0..njs {
1505                // CONT: AJ, 0, INT, 0, N1=6*(NE+1), N2=NE
1506                let j_cont = parse_cont(ctx.lines, ctx.pos)?;
1507                let aj = j_cont.c1;
1508                let int_code = j_cont.l1; // interpolation law (L1 field)
1509                // ENDF-6 §0.5 defines INT codes 1..=5 (1=histogram, 2=lin-lin,
1510                // 3=log-x/lin-y, 4=lin-x/log-y, 5=log-log). Anything outside
1511                // that range — including negative values and INT=0 or INT≥6 —
1512                // is a malformed record, not merely an unimplemented mode.
1513                if !(1..=5).contains(&int_code) {
1514                    return Err(EndfParseError::UnsupportedFormat(format!(
1515                        "URR LRF=2 J={aj}: INT={int_code} out of spec (expected 1..=5)"
1516                    )));
1517                }
1518                let n1 = checked_count(j_cont.n1, "N1")?; // 6*(NE+1)
1519                let ne = checked_count(j_cont.n2, "NE")?; // NE (number of energy points)
1520
1521                // Validate N1 = 6*(NE+1) before consuming any LIST body.
1522                // This catches malformed records regardless of whether the INT
1523                // code is supported, preventing over-/under-consumption of lines.
1524                // SAMMY only validates this for LFW=1/LRF=1 (File2.cpp line 1031);
1525                // we validate unconditionally since we actually parse URR data.
1526                let expected_n1 = 6 * (ne + 1);
1527                if n1 != expected_n1 {
1528                    return Err(EndfParseError::UnsupportedFormat(format!(
1529                        "URR LRF=2 J={aj}: N1={n1} ≠ 6*(NE+1)={expected_n1} (NE={ne})"
1530                    )));
1531                }
1532
1533                // All ENDF interpolation laws (INT=1..5) are now supported
1534                // in the URR physics module (urr.rs).
1535                // INT=1: histogram, INT=2: lin-lin, INT=3: log-x/lin-y,
1536                // INT=4: lin-x/log-y, INT=5: log-log.
1537                // ENDF-6 §2.2.2.2.
1538
1539                let values = parse_list_values(ctx.lines, ctx.pos, n1)?;
1540
1541                // Row 0 (DOF): [0, 0, 0, AMUN, 0, AMUF]
1542                let amun = values[3];
1543                let amuf = values[5];
1544
1545                // Rows 1..NE: [E_i, D_i, GX_i, GN_i, GG_i, GF_i]
1546                let mut energies = Vec::with_capacity(ne);
1547                let mut d = Vec::with_capacity(ne);
1548                let mut gx = Vec::with_capacity(ne);
1549                let mut gn = Vec::with_capacity(ne);
1550                let mut gg = Vec::with_capacity(ne);
1551                let mut gf = Vec::with_capacity(ne);
1552
1553                for row in 0..ne {
1554                    let base = (row + 1) * 6; // +1 to skip the DOF row
1555                    energies.push(values[base]);
1556                    d.push(values[base + 1]);
1557                    gx.push(values[base + 2]);
1558                    gn.push(values[base + 3]);
1559                    gg.push(values[base + 4]);
1560                    gf.push(values[base + 5]);
1561                }
1562
1563                // Deduplicate energy grid: some evaluations (e.g., JENDL-5
1564                // Eu-151, Eu-153) contain duplicate energy points. SAMMY
1565                // silently accepts these. We keep the LAST occurrence of
1566                // each duplicate energy, matching SAMMY behavior.
1567                // Exact f64 equality is correct: ENDF duplicates are
1568                // bitwise-identical copies in the same file record.
1569                // Issue: #402
1570                {
1571                    let n = energies.len();
1572                    if n > 1 {
1573                        // O(n) backwards compaction: keep last of each run.
1574                        let mut write = n - 1;
1575                        let mut last_e = energies[n - 1];
1576                        let mut read = n - 1;
1577                        while read > 0 {
1578                            read -= 1;
1579                            if energies[read] == last_e {
1580                                continue;
1581                            }
1582                            write -= 1;
1583                            energies[write] = energies[read];
1584                            d[write] = d[read];
1585                            gx[write] = gx[read];
1586                            gn[write] = gn[read];
1587                            gg[write] = gg[read];
1588                            gf[write] = gf[read];
1589                            last_e = energies[read];
1590                        }
1591                        let new_len = n - write;
1592                        energies.copy_within(write..n, 0);
1593                        d.copy_within(write..n, 0);
1594                        gx.copy_within(write..n, 0);
1595                        gn.copy_within(write..n, 0);
1596                        gg.copy_within(write..n, 0);
1597                        gf.copy_within(write..n, 0);
1598                        energies.truncate(new_len);
1599                        d.truncate(new_len);
1600                        gx.truncate(new_len);
1601                        gn.truncate(new_len);
1602                        gg.truncate(new_len);
1603                        gf.truncate(new_len);
1604                    }
1605                }
1606
1607                // Validate that the (now deduplicated) URR energy grid is
1608                // strictly ascending (precondition of table_interp).
1609                for i in 0..energies.len().saturating_sub(1) {
1610                    if energies[i] >= energies[i + 1] {
1611                        return Err(EndfParseError::UnsupportedFormat(format!(
1612                            "URR energy grid must be strictly ascending \
1613                             (AJ={aj}, index {i}: {} >= {})",
1614                            energies[i],
1615                            energies[i + 1]
1616                        )));
1617                    }
1618                }
1619
1620                j_groups.push(UrrJGroup {
1621                    j: aj,
1622                    amun,
1623                    amuf,
1624                    energies,
1625                    d,
1626                    gx,
1627                    gn,
1628                    gg,
1629                    gf,
1630                    // INT was validated to be in 1..=5 immediately after
1631                    // parsing the per-J CONT record, so this cast is safe.
1632                    // (urr.rs:130-136 dispatches on the full INT=1..=5 set.)
1633                    int_code: int_code as u32,
1634                });
1635            }
1636
1637            l_groups.push(UrrLGroup { l, awri, j_groups });
1638        }
1639    }
1640
1641    // ENDF-6 §2.2.2: LRF for URR is 1 or 2. Guard before i32→u32 cast.
1642    debug_assert!(lrf == 1 || lrf == 2, "URR LRF must be 1 or 2, got: {lrf}");
1643
1644    let urr = UrrData {
1645        lrf: lrf as u32,
1646        spi,
1647        ap,
1648        e_low: ctx.energy_low,
1649        e_high: ctx.energy_high,
1650        l_groups,
1651    };
1652
1653    Ok(ResonanceRange {
1654        energy_low: ctx.energy_low,
1655        energy_high: ctx.energy_high,
1656        resolved: false,
1657        formalism: ResonanceFormalism::Unresolved,
1658        target_spin: spi,
1659        scattering_radius: ap,
1660        naps: ctx.naps,
1661        ap_table: ctx.ap_table.take(),
1662        l_groups: Vec::new(),
1663        rml: None,
1664        urr: Some(Box::new(urr)),
1665        r_external: vec![],
1666    })
1667}
1668
1669/// Parse a TAB1 record into a `Tab1` interpolation table.
1670///
1671/// ENDF TAB1 layout (Reference: ENDF-6 Formats Manual §0.5):
1672/// ```text
1673/// CONT: [C1, C2, L1, L2, NR, NP]
1674/// NR×2 integer values: (NBT_i, INT_i) pairs  — 6 per line
1675/// NP×2 float values:   (x_i,   y_i)   pairs  — 6 per line
1676/// ```
1677///
1678/// INT codes: 1=histogram, 2=lin-lin, 3=log-x/lin-y, 4=lin-x/log-y, 5=log-log.
1679fn parse_tab1(lines: &[&str], pos: &mut usize) -> Result<Tab1, EndfParseError> {
1680    let cont = parse_cont(lines, pos)?;
1681    let nr = checked_count(cont.n1, "NR")?; // number of interpolation regions
1682    let np = checked_count(cont.n2, "NP")?; // number of data points
1683
1684    // NR=0 is valid ENDF: it means a single implicit interpolation region
1685    // covering all NP points with no explicit boundary record.  The
1686    // evaluate() call will fall through to the `unwrap_or(2)` default in
1687    // interp_code_for_interval(), which correctly returns INT=2 (lin-lin).
1688    // When NR=0, the loop below is a no-op and the interp_raw vec stays empty.
1689
1690    // Read NR×2 integers: (NBT, INT) pairs packed as ENDF floats.
1691    // Validate that values are integers, INT codes are in 1..=5, boundaries
1692    // are strictly increasing, and the last boundary equals NP.
1693    let interp_raw = parse_list_values(lines, pos, nr * 2)?;
1694    let mut boundaries = Vec::with_capacity(nr);
1695    let mut interp_codes = Vec::with_capacity(nr);
1696    for i in 0..nr {
1697        let nbt_raw = interp_raw[i * 2];
1698        let int_raw = interp_raw[i * 2 + 1];
1699
1700        // ENDF stores integers as floats (e.g. "2.000000+0").  They must be
1701        // exact whole numbers.  Use a small epsilon (1e-6) rather than the
1702        // half-unit tolerance 0.5, which would silently accept 1.4 or 2.49.
1703        // NBT is a 1-based index (ENDF §0.5), so 0 is invalid.
1704        if (nbt_raw - nbt_raw.round()).abs() > 1e-6 || nbt_raw < 1.0 {
1705            return Err(EndfParseError::UnsupportedFormat(format!(
1706                "TAB1 NBT[{}] is not a positive integer: {}",
1707                i, nbt_raw
1708            )));
1709        }
1710        if (int_raw - int_raw.round()).abs() > 1e-6 {
1711            return Err(EndfParseError::UnsupportedFormat(format!(
1712                "TAB1 INT[{}] is not an integer: {}",
1713                i, int_raw
1714            )));
1715        }
1716        let int_code = int_raw.round() as u32;
1717        if !(1..=5).contains(&int_code) {
1718            return Err(EndfParseError::UnsupportedFormat(format!(
1719                "TAB1 INT[{}]={} is out of range 1..=5",
1720                i, int_code
1721            )));
1722        }
1723        let nbt = nbt_raw.round() as usize;
1724
1725        // Boundaries must be strictly increasing (ENDF §0.5).
1726        if let Some(&prev) = boundaries.last()
1727            && nbt <= prev
1728        {
1729            return Err(EndfParseError::UnsupportedFormat(format!(
1730                "TAB1 NBT[{}]={} is not greater than NBT[{}]={}",
1731                i,
1732                nbt,
1733                i - 1,
1734                prev
1735            )));
1736        }
1737        boundaries.push(nbt);
1738        interp_codes.push(int_code);
1739    }
1740
1741    // The final boundary must equal NP (ENDF §0.5: last NBT is 1-based index of last point).
1742    if nr > 0 {
1743        let last_nbt = *boundaries.last().unwrap();
1744        if last_nbt != np {
1745            return Err(EndfParseError::UnsupportedFormat(format!(
1746                "TAB1 last NBT={} does not equal NP={}",
1747                last_nbt, np
1748            )));
1749        }
1750    }
1751
1752    if np == 0 {
1753        return Err(EndfParseError::UnsupportedFormat(
1754            "TAB1 NP=0: table must have at least one point".to_string(),
1755        ));
1756    }
1757
1758    // Read NP×2 floats: (E, AP) pairs.
1759    let data_raw = parse_list_values(lines, pos, np * 2)?;
1760    let mut points = Vec::with_capacity(np);
1761    for i in 0..np {
1762        let x = data_raw[i * 2];
1763        let y = data_raw[i * 2 + 1];
1764        // x-values must be strictly increasing; Tab1::evaluate() relies on this.
1765        if let Some(&(x_prev, _)) = points.last()
1766            && x <= x_prev
1767        {
1768            return Err(EndfParseError::UnsupportedFormat(format!(
1769                "TAB1 x[{}]={} is not greater than x[{}]={} (x must be strictly increasing)",
1770                i,
1771                x,
1772                i - 1,
1773                x_prev
1774            )));
1775        }
1776        points.push((x, y));
1777    }
1778
1779    Ok(Tab1 {
1780        boundaries,
1781        interp_codes,
1782        points,
1783    })
1784}
1785
1786/// Errors from ENDF parsing.
1787#[derive(Debug, thiserror::Error)]
1788pub enum EndfParseError {
1789    #[error("Missing section: {0}")]
1790    MissingSection(String),
1791
1792    #[error("Unsupported format: {0}")]
1793    UnsupportedFormat(String),
1794
1795    #[error("Invalid number: {0}")]
1796    InvalidNumber(String),
1797
1798    #[error("Unexpected end of file: {0}")]
1799    UnexpectedEof(String),
1800
1801    #[error("Invalid isotope: {0}")]
1802    InvalidIsotope(#[from] nereids_core::error::NereidsError),
1803}
1804
1805#[cfg(test)]
1806mod tests {
1807    use super::*;
1808
1809    // NOTE: Every ENDF test fixture line must be at least 75 characters long.
1810    // The MF/MT filter in `parse_endf_file2` checks `line.len() < 75` and
1811    // discards shorter lines.  ENDF lines are exactly 80 characters in the
1812    // real format.  If a test fixture line is truncated below 75 chars, it
1813    // will be silently dropped and the test will fail with "No MF=2, MT=151
1814    // data found" rather than a useful error.
1815
1816    #[test]
1817    fn test_parse_endf_float_standard() {
1818        // ENDF fields are exactly 11 chars wide, no separators.
1819        // " 1.23456+2" in 11 chars = " 1.23456+02" (Fortran E11.4 style)
1820        //  01234567890  (field 0: cols 0-10, field 1: cols 11-21, etc.)
1821        let line = " 1.23456+02 2.34567-01 0.00000+00                                            ";
1822        assert!((parse_endf_float(line, 0).unwrap() - 123.456).abs() < 0.01);
1823        assert!((parse_endf_float(line, 1).unwrap() - 0.234567).abs() < 1e-6);
1824        assert!((parse_endf_float(line, 2).unwrap() - 0.0).abs() < 1e-10);
1825    }
1826
1827    #[test]
1828    fn test_parse_endf_float_with_e() {
1829        // 11-char fields: "1.23456E+02" "2.34567E-01"
1830        let line = "1.23456E+022.34567E-01                                                       ";
1831        assert!((parse_endf_float(line, 0).unwrap() - 123.456).abs() < 0.01);
1832        assert!((parse_endf_float(line, 1).unwrap() - 0.234567).abs() < 1e-6);
1833    }
1834
1835    #[test]
1836    fn test_parse_endf_float_negative() {
1837        let line = "-1.23456+02-2.34567-01                                                       ";
1838        assert!((parse_endf_float(line, 0).unwrap() - (-123.456)).abs() < 0.01);
1839        assert!((parse_endf_float(line, 1).unwrap() - (-0.234567)).abs() < 1e-6);
1840    }
1841
1842    /// Fortran exponents with a space between the sign and digit — e.g. "9.22330+ 4"
1843    /// — appear in some older ENDF evaluations (observed in SAMMY tr149/t149a.endf
1844    /// for U-233).  The parser strips the space before parsing the exponent.
1845    #[test]
1846    fn test_parse_endf_float_spaced_exponent() {
1847        // " 9.22330+ 4" occupies 11 chars: space before mantissa, space before digit
1848        let line =
1849            " 9.22330+ 4 1.23400- 2                                                         ";
1850        assert!((parse_endf_float(line, 0).unwrap() - 92_233.0).abs() < 1.0);
1851        assert!((parse_endf_float(line, 1).unwrap() - 0.01234).abs() < 1e-6);
1852    }
1853
1854    #[test]
1855    fn test_parse_endf_int() {
1856        let line = "          0          1          2          3          4          5            ";
1857        assert_eq!(parse_endf_int(line, 0).unwrap(), 0);
1858        assert_eq!(parse_endf_int(line, 1).unwrap(), 1);
1859        assert_eq!(parse_endf_int(line, 2).unwrap(), 2);
1860    }
1861
1862    /// Parse the vendored U-238 ENDF file (Reich-Moore, LRF=3).
1863    ///
1864    /// This test validates against the public-domain U-238 ENDF/B-VIII.0
1865    /// evaluation shipped at `examples/data/u238_ex027.endf` (the same
1866    /// file SAMMY distributes as `samexm_new/ex027_new/ex027.endf`). The
1867    /// first positive-energy resonance of U-238 is at 6.674 eV.
1868    ///
1869    /// Vendored under public-domain ENDF/B redistribution, so this gate
1870    /// runs unconditionally on CI — no `Skipping…` fall-through.
1871    #[test]
1872    fn test_parse_u238_sammy_endf() {
1873        // Crate-local copy so the test works when nereids-endf is built
1874        // standalone (outside the workspace, where `examples/data/` is
1875        // not packaged).  The original `examples/data/u238_ex027.endf`
1876        // is kept for end-user example code.
1877        let endf_path =
1878            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/u238_ex027.endf");
1879
1880        let endf_text = std::fs::read_to_string(&endf_path)
1881            .unwrap_or_else(|e| panic!("vendored U-238 fixture missing at {endf_path:?}: {e}"));
1882        let data = parse_endf_file2(&endf_text).unwrap();
1883
1884        // Basic structure checks.
1885        assert_eq!(data.za, 92238, "Should be U-238");
1886        assert!((data.awr - 236.006).abs() < 0.01, "AWR should be ~236");
1887        assert!(!data.ranges.is_empty(), "Should have at least one range");
1888
1889        let range = &data.ranges[0];
1890        assert!(range.resolved, "First range should be resolved");
1891        assert_eq!(
1892            range.formalism,
1893            ResonanceFormalism::ReichMoore,
1894            "U-238 ENDF uses Reich-Moore (LRF=3)"
1895        );
1896        assert!(
1897            (range.target_spin - 0.0).abs() < 1e-10,
1898            "U-238 target spin I=0"
1899        );
1900        assert!(
1901            (range.scattering_radius - 9.4285).abs() < 0.01,
1902            "Scattering radius ~9.4285 fm (ENDF 0.94285 × 10)"
1903        );
1904        assert_eq!(range.l_groups.len(), 2, "Should have L=0 and L=1 groups");
1905
1906        // Check first L-group (L=0).
1907        let l0 = &range.l_groups[0];
1908        assert_eq!(l0.l, 0, "First group should be L=0");
1909        assert!(
1910            l0.resonances.len() > 500,
1911            "L=0 should have hundreds of resonances"
1912        );
1913
1914        // Find the famous 6.674 eV resonance of U-238.
1915        let first_positive = l0
1916            .resonances
1917            .iter()
1918            .find(|r| r.energy > 0.0)
1919            .expect("Should have positive-energy resonances");
1920        assert!(
1921            (first_positive.energy - 6.674).abs() < 0.01,
1922            "First positive resonance should be at 6.674 eV, got {}",
1923            first_positive.energy
1924        );
1925        assert!(
1926            (first_positive.j - 0.5).abs() < 1e-10,
1927            "6.674 eV resonance has J=0.5"
1928        );
1929
1930        // The 6.674 eV resonance neutron width: ~1.493e-3 eV
1931        assert!(
1932            (first_positive.gn - 1.493e-3).abs() < 1e-5,
1933            "Neutron width should be ~1.493e-3 eV, got {}",
1934            first_positive.gn
1935        );
1936        // Gamma width: ~2.3e-2 eV
1937        assert!(
1938            (first_positive.gg - 2.3e-2).abs() < 1e-3,
1939            "Gamma width should be ~2.3e-2 eV, got {}",
1940            first_positive.gg
1941        );
1942
1943        let total = data.total_resonance_count();
1944        println!(
1945            "U-238 ENDF parsed successfully: {} total resonances across {} L-groups",
1946            total,
1947            range.l_groups.len()
1948        );
1949        println!(
1950            "  L=0: {} resonances, L=1: {} resonances",
1951            l0.resonances.len(),
1952            range.l_groups[1].resonances.len()
1953        );
1954    }
1955
1956    /// Verify KRM=3 resonance column order (offline fixture — no network needed).
1957    ///
1958    /// For KRM=3 the per-resonance ENDF layout is [ER, Γγ, Γ_1, ..., Γ_NCH, padding].
1959    /// The regression checks that `gamma_gamma` comes from position b+1 (Γγ) and
1960    /// `widths[0]` from position b+2 (Γ_1), NOT the other way round.
1961    ///
1962    /// Constructed values:
1963    ///   res0: ER=10 eV, Γγ=0.025 eV, Γ_1=0.001 eV
1964    ///   res1: ER=20 eV, Γγ=0.030 eV, Γ_1=0.002 eV
1965    ///
1966    /// The fixture is a minimal but fully valid ENDF MF=2/MT=151 block:
1967    ///   1 isotope, 1 energy range, LRF=7, KRM=3, 1 particle pair, 1 spin group,
1968    ///   2 resonances, NCH=1 (single elastic neutron channel).
1969    #[test]
1970    fn test_krm3_resonance_column_order() {
1971        // Each ENDF line is exactly 80 chars:
1972        //   positions  0-65: six 11-char data fields
1973        //   positions 66-69: MAT (4 chars)
1974        //   positions 70-71: MF (2 chars)
1975        //   positions 72-74: MT (3 chars)
1976        //   positions 75-79: NS (5 chars)
1977        //
1978        // Floats use Fortran notation, e.g. "1.000000+1" = 1e1 = 10.0.
1979        // Integer fields written as right-justified 11-char strings.
1980        const ENDF: &str =
1981            include_str!("../../../tests/data/synthetic/lrf7_krm3_resonance_column_order.endf");
1982
1983        let data = parse_endf_file2(ENDF).expect("fixture must parse without error");
1984        let rml = data.ranges[0]
1985            .rml
1986            .as_ref()
1987            .expect("LRF=7 range must have RmlData");
1988        let sg = &rml.spin_groups[0];
1989
1990        assert_eq!(sg.resonances.len(), 2, "spin group must have 2 resonances");
1991
1992        let res0 = &sg.resonances[0];
1993        assert!(
1994            (res0.energy - 10.0).abs() < 1e-10,
1995            "res0 energy must be 10.0 eV, got {}",
1996            res0.energy
1997        );
1998        // The critical assertions: Γγ must come from column b+1, Γ_1 from column b+2.
1999        // With the old (buggy) code these two values were swapped.
2000        assert!(
2001            (res0.gamma_gamma - 0.025).abs() < 1e-10,
2002            "res0 gamma_gamma must be 0.025 eV (Gamgam at b+1), got {}",
2003            res0.gamma_gamma
2004        );
2005        assert_eq!(res0.widths.len(), 1, "NCH=1 so widths must have 1 element");
2006        assert!(
2007            (res0.widths[0] - 0.001).abs() < 1e-10,
2008            "res0 widths[0] must be 0.001 eV (Γ_1 at b+2), got {}",
2009            res0.widths[0]
2010        );
2011
2012        let res1 = &sg.resonances[1];
2013        assert!(
2014            (res1.energy - 20.0).abs() < 1e-10,
2015            "res1 energy must be 20.0 eV"
2016        );
2017        assert!(
2018            (res1.gamma_gamma - 0.030).abs() < 1e-10,
2019            "res1 gamma_gamma must be 0.030 eV, got {}",
2020            res1.gamma_gamma
2021        );
2022        assert!(
2023            (res1.widths[0] - 0.002).abs() < 1e-10,
2024            "res1 widths[0] must be 0.002 eV, got {}",
2025            res1.widths[0]
2026        );
2027    }
2028
2029    /// KRM=2 spin group with an explicit photon capture channel (IPP=2, MA=0).
2030    ///
2031    /// Before issue #45 the parser rejected MA<0.5 channels with UnsupportedFormat.
2032    /// This test verifies that photon channels are now parsed and stored correctly:
2033    ///   - channels[1] points to the photon particle pair (MT=102)
2034    ///   - res.widths has two entries: [γ_elastic, γ_photon]
2035    #[test]
2036    fn test_krm2_explicit_photon_channel() {
2037        // Minimal synthetic LRF=7, KRM=2, NJS=1 ENDF snippet.
2038        // Two particle pairs: pair 1 = n+W184 (MT=2), pair 2 = γ+W185 (MT=102, MA=0).
2039        // One spin group with 2 channels (elastic + photon); one resonance.
2040        //
2041        // Each ENDF line is 80 chars: 6×11-char fields + MAT(4)+MF(2)+MT(3)+NS(5).
2042        const ENDF: &str =
2043            include_str!("../../../tests/data/synthetic/lrf7_krm2_explicit_photon_channel.endf");
2044
2045        let data = parse_endf_file2(ENDF).expect("KRM=2 photon channel must parse without error");
2046        let rml = data.ranges[0]
2047            .rml
2048            .as_ref()
2049            .expect("LRF=7 range must have RmlData");
2050
2051        assert_eq!(rml.krm, 2, "KRM must be 2");
2052        assert_eq!(rml.particle_pairs.len(), 2, "must have 2 particle pairs");
2053        assert!(
2054            rml.particle_pairs[1].ma < 0.5,
2055            "pair 2 must be massless (photon)"
2056        );
2057        assert_eq!(
2058            rml.particle_pairs[1].mt, 102,
2059            "pair 2 must be MT=102 capture"
2060        );
2061
2062        let sg = &rml.spin_groups[0];
2063        assert_eq!(sg.channels.len(), 2, "spin group must have 2 channels");
2064        assert_eq!(
2065            sg.channels[0].particle_pair_idx, 0,
2066            "channel 0 must point to pair 0 (elastic)"
2067        );
2068        assert_eq!(
2069            sg.channels[1].particle_pair_idx, 1,
2070            "channel 1 must point to pair 1 (photon)"
2071        );
2072
2073        assert_eq!(sg.resonances.len(), 1, "must have 1 resonance");
2074        let res = &sg.resonances[0];
2075        assert!((res.energy - 10.0).abs() < 1e-10, "energy must be 10 eV");
2076        assert_eq!(res.widths.len(), 2, "widths must have 2 entries (NCH=2)");
2077        assert!(
2078            (res.widths[0] - 0.001).abs() < 1e-10,
2079            "widths[0] (elastic) must be 0.001, got {}",
2080            res.widths[0]
2081        );
2082        assert!(
2083            (res.widths[1] - 0.004).abs() < 1e-10,
2084            "widths[1] (photon) must be 0.004, got {}",
2085            res.widths[1]
2086        );
2087    }
2088
2089    /// Parse a minimal hand-crafted ENDF snippet with NRO=1 (energy-dependent
2090    /// scattering radius).
2091    ///
2092    /// The fixture encodes:
2093    /// - LRF=3 (Reich-Moore), NRO=1
2094    /// - AP TAB1: 2 points — ENDF values 8.0 and 10.0 (10⁻¹² cm),
2095    ///   which become 80.0 fm and 100.0 fm after ×10 conversion
2096    /// - One L-group (L=0) with one resonance at 6.674 eV
2097    ///
2098    /// Verifies:
2099    /// - ap_table is Some after parsing
2100    /// - ap_table.evaluate(1.0) ≈ 80.0 fm (8.0 × ENDF_RADIUS_TO_FM)
2101    /// - ap_table.evaluate(500.5) ≈ 90.0 fm (midpoint, lin-lin)
2102    /// - ap_table.evaluate(1000.0) ≈ 100.0 fm
2103    /// - scattering_radius_at() delegates to the table
2104    #[test]
2105    fn test_parse_nro1_tab1() {
2106        // Each ENDF line is exactly 80 chars: 66 data chars + 14 MAT/MF/MT/SEQ.
2107        // Cols 67-70: MAT=9237, Cols 71-72: MF=2, Cols 73-75: MT=151, Cols 76-80: seq
2108        //
2109        // Line layout (11 chars per field × 6 fields = 66 chars, then 14 control chars):
2110        //   HEAD:  ZA=92238  AWR=236.006  0  0  NIS=1  0
2111        //   CONT:  ZAI=92238 ABN=1.0      0  LFW=0 NER=1  0
2112        //   CONT:  EL=1e-5   EH=1e4    LRU=1  LRF=3  NRO=1  NAPS=0
2113        //   TAB1 CONT: 0  0  0  0  NR=1  NP=2
2114        //   TAB1 interp: NBT=2, INT=2  (plus 4 padding zeros)
2115        //   TAB1 data:   (1.0, 8.0), (1000.0, 10.0)
2116        //   RM CONT:  SPI=0.0  AP=9.0  0  0  NLS=1  0
2117        //   L CONT:  AWRI=236.006  0  L=0  0  6*NRS=6  NRS=1
2118        //   Resonance: ER=6.674  AJ=0.5  GN=1.493e-3  GG=23e-3  GFA=0  GFB=0
2119        //   SEND: all zeros
2120        // Each ENDF line: 66 data chars + 4-char MAT(9237) + 2-char MF(" 2")
2121        //   + 3-char MT("151") + 5-char SEQ = 80 chars total.
2122        let endf = include_str!("../../../tests/data/synthetic/lrf3_nro1_tab1.endf");
2123
2124        let data = parse_endf_file2(endf).expect("NRO=1 fixture must parse cleanly");
2125        assert_eq!(data.ranges.len(), 1, "one energy range");
2126
2127        let range = &data.ranges[0];
2128        assert_eq!(
2129            range.formalism,
2130            ResonanceFormalism::ReichMoore,
2131            "must be LRF=3"
2132        );
2133
2134        let table = range
2135            .ap_table
2136            .as_ref()
2137            .expect("NRO=1 range must have ap_table");
2138        assert_eq!(table.points.len(), 2, "TAB1 must have 2 points");
2139
2140        // Exact boundary values (ENDF 8.0 × 10 = 80.0 fm).
2141        assert!(
2142            (table.evaluate(1.0) - 80.0).abs() < 1e-10,
2143            "AP(1 eV) = 80.0 fm"
2144        );
2145        assert!(
2146            (table.evaluate(1000.0) - 100.0).abs() < 1e-10,
2147            "AP(1000 eV) = 100.0 fm"
2148        );
2149        // Lin-lin midpoint: AP(500.5 eV) ≈ 90.0 fm.
2150        let mid = table.evaluate(500.5);
2151        assert!((mid - 90.0).abs() < 0.1, "AP midpoint ≈ 90.0 fm, got {mid}");
2152
2153        // scattering_radius_at delegates to the table.
2154        assert!(
2155            (range.scattering_radius_at(1.0) - 80.0).abs() < 1e-10,
2156            "scattering_radius_at(1 eV) = 80.0"
2157        );
2158        assert!(
2159            (range.scattering_radius_at(1000.0) - 100.0).abs() < 1e-10,
2160            "scattering_radius_at(1000 eV) = 100.0"
2161        );
2162
2163        // Resonance is still parsed correctly.
2164        assert_eq!(range.l_groups.len(), 1, "one L-group");
2165        let res = &range.l_groups[0].resonances[0];
2166        assert!((res.energy - 6.674).abs() < 1e-6);
2167    }
2168
2169    /// LFW=1 with LRF=2 (tabulated widths, U-233-style record).
2170    ///
2171    /// SAMMY test tr149 (`t149a.endf`, MAT=9222, ZA=92233) has two ranges:
2172    ///   - Range 0: LRU=1 (resolved, Reich-Moore / LRF=3)
2173    ///   - Range 1: LRU=2, LRF=2, **LFW=1** (energy-dependent fission widths)
2174    ///
2175    /// ENDF-6 §2.2.2.2: for LFW=1/LRF=2 the per-(L,J) LIST layout is
2176    /// **identical to LFW=0/LRF=2** (the fission widths are already
2177    /// per-energy-point in the LIST tail), so the parser dispatches to
2178    /// the shared `parse_urr_range` path and produces full URR data.
2179    ///
2180    /// We previously gated this assertion on a `../SAMMY/...t149a.endf`
2181    /// sibling checkout; on CI (and on any clean clone) the file was
2182    /// absent and the test silently reported `ok` after a `Skipping…`
2183    /// print. Vendoring the full tr149 ENDF would be heavy; instead we
2184    /// synthesise a minimal but record-shape-faithful LFW=1/LRF=2
2185    /// fixture so the assertion runs unconditionally.
2186    #[test]
2187    fn test_parse_u233_lfw1_lrf2_urr_parsed() {
2188        // Minimal MF=2/MT=151 with two ranges, mirroring tr149 layout:
2189        //   Range 0: LRU=1, LRF=3 (Reich-Moore) — one trivial resonance.
2190        //   Range 1: LRU=2, LRF=2, LFW=1       — NLS=1, NJS=1, NE=2.
2191        // LFW=1 is flagged on the isotope CONT (L2 field).
2192        const ENDF: &str = include_str!("../../../tests/data/synthetic/u233_lfw1_lrf2_urr.endf");
2193
2194        let data = parse_endf_file2(ENDF)
2195            .expect("U-233 LFW=1/LRF=2 fixture must parse (record layout = LFW=0/LRF=2)");
2196
2197        // Both ranges must round-trip: resolved + URR.
2198        assert_eq!(data.ranges.len(), 2, "must have 2 ranges (resolved + URR)");
2199
2200        let resolved_count = data.ranges.iter().filter(|r| r.resolved).count();
2201        assert_eq!(resolved_count, 1, "exactly one resolved range");
2202
2203        let urr_count = data.ranges.iter().filter(|r| r.urr.is_some()).count();
2204        assert_eq!(urr_count, 1, "LFW=1/LRF=2 URR range must be parsed");
2205
2206        // Select the URR range by predicate rather than by index — the
2207        // fixture happens to place the URR at index 1, but the assertion
2208        // should remain valid if the resolved/URR ordering ever changes.
2209        let urr = data
2210            .ranges
2211            .iter()
2212            .find_map(|r| r.urr.as_deref())
2213            .expect("URR range must exist (already asserted by urr_count above)");
2214        assert_eq!(urr.lrf, 2, "URR LRF must be 2");
2215        assert_eq!(urr.l_groups.len(), 1, "one L-group");
2216        let jg = &urr.l_groups[0].j_groups[0];
2217        assert_eq!(
2218            jg.gf.len(),
2219            2,
2220            "LFW=1/LRF=2 must carry NE per-energy fission widths"
2221        );
2222        assert!((jg.gf[0] - 1e-3).abs() < 1e-14, "GF[0]={}", jg.gf[0]);
2223        assert!((jg.gf[1] - 2e-3).abs() < 1e-14, "GF[1]={}", jg.gf[1]);
2224        assert!((jg.amuf - 1.0).abs() < 1e-14, "AMUF must round-trip as 1");
2225    }
2226
2227    /// Hand-crafted LRF=1 URR roundtrip test.
2228    ///
2229    /// Verifies that a minimal synthetic ENDF snippet with LRU=2, LRF=1 is
2230    /// parsed correctly: one L-group (L=0), two J-groups with known D, AJ,
2231    /// AMUN, GNO, GG, GF values.
2232    #[test]
2233    fn test_parse_lrf1_urr_roundtrip() {
2234        // Minimal ENDF MF=2/MT=151 with one resolved range followed by one
2235        // LRU=2 LRF=1 unresolved range.
2236        //
2237        // Resolved range: a simple RM LRF=3 with one resonance (gives the
2238        // parser something valid to consume before the URR section).
2239        //
2240        // URR range: LRU=2, LRF=1, NLS=1 (L=0), NJS=2 J-groups.
2241        //   J=2.0: D=0.5 eV, AMUN=1, GNO=3e-4 eV, GG=3.5e-2 eV, GF=0
2242        //   J=3.0: D=0.4 eV, AMUN=1, GNO=2e-4 eV, GG=3.0e-2 eV, GF=1e-3 eV
2243        //
2244        // Each ENDF line: 66 data chars + MAT(4) MF(2) MT(3) SEQ(5) = 80 chars.
2245        const ENDF: &str = include_str!("../../../tests/data/synthetic/lrf1_urr_roundtrip.endf");
2246
2247        let data = parse_endf_file2(ENDF).expect("LRF=1 URR fixture must parse cleanly");
2248
2249        // Should have 2 ranges: one resolved + one URR.
2250        assert_eq!(data.ranges.len(), 2, "must have 2 ranges");
2251
2252        let urr_range = &data.ranges[1];
2253        assert!(!urr_range.resolved, "URR range must not be resolved");
2254        assert_eq!(
2255            urr_range.formalism,
2256            ResonanceFormalism::Unresolved,
2257            "formalism must be Unresolved"
2258        );
2259
2260        let urr = urr_range
2261            .urr
2262            .as_ref()
2263            .expect("URR range must have urr data");
2264        assert_eq!(urr.lrf, 1, "LRF must be 1");
2265        assert!((urr.spi - 2.5).abs() < 1e-10, "SPI must be 2.5");
2266        assert!((urr.e_low - 600.0).abs() < 1.0, "e_low must be 600 eV");
2267        assert!(
2268            (urr.e_high - 30_000.0).abs() < 1.0,
2269            "e_high must be 30 000 eV"
2270        );
2271
2272        assert_eq!(urr.l_groups.len(), 1, "must have 1 L-group");
2273        let lg = &urr.l_groups[0];
2274        assert_eq!(lg.l, 0, "L must be 0");
2275        assert!((lg.awri - 231.038).abs() < 0.001, "AWRI must be 231.038");
2276        assert_eq!(lg.j_groups.len(), 2, "must have 2 J-groups");
2277
2278        let jg0 = &lg.j_groups[0];
2279        assert!((jg0.j - 2.0).abs() < 1e-10, "first J must be 2.0");
2280        assert!(jg0.energies.is_empty(), "LRF=1 energies must be empty");
2281        assert!((jg0.d[0] - 0.5).abs() < 1e-10, "D must be 0.5 eV");
2282        assert!((jg0.amun - 1.0).abs() < 1e-10, "AMUN must be 1.0");
2283        assert!((jg0.gn[0] - 3e-4).abs() < 1e-14, "GNO must be 3e-4 eV");
2284        assert!((jg0.gg[0] - 3.5e-2).abs() < 1e-12, "GG must be 3.5e-2 eV");
2285        assert!((jg0.gf[0] - 0.0).abs() < 1e-14, "GF must be 0");
2286
2287        let jg1 = &lg.j_groups[1];
2288        assert!((jg1.j - 3.0).abs() < 1e-10, "second J must be 3.0");
2289        assert!((jg1.d[0] - 0.4).abs() < 1e-10, "D must be 0.4 eV");
2290        assert!((jg1.gn[0] - 2e-4).abs() < 1e-14, "GNO must be 2e-4 eV");
2291        assert!((jg1.gf[0] - 1e-3).abs() < 1e-14, "GF must be 1e-3 eV");
2292    }
2293
2294    /// LRF=2 URR with INT=3 (log-x / lin-y) parses successfully.
2295    ///
2296    /// Pins issue #553 / M2: between commit 9d7c6bb (which removed the
2297    /// INT=1/3/4 early-return guard in the URR LRF=2 path and wired the
2298    /// full INT=1..=5 dispatch in urr.rs) and this PR, a stale
2299    /// `debug_assert!(int_code == 2 || int_code == 5, …)` survived in
2300    /// the LIST consumer block. Debug builds therefore panicked on
2301    /// otherwise valid INT=1, 3, or 4 evaluations; release builds — used
2302    /// for `cargo test --release` and for end-user binaries — worked
2303    /// correctly because `debug_assert!` is compiled out.
2304    ///
2305    /// This test is **explicitly written to fail in debug builds against
2306    /// the pre-fix parser** and to pass under both `cargo test` and
2307    /// `cargo test --release` once the assertion is removed and the INT
2308    /// code is validated up-front (1..=5).
2309    #[test]
2310    fn test_parse_lrf2_urr_int3_roundtrip() {
2311        // Minimal ENDF MF=2/MT=151 with one LRU=2/LRF=2 range:
2312        // NLS=1 (L=0), NJS=1, NE=2 energy points, INT=3 (log-x/lin-y).
2313        // LIST layout: row 0 = [0, 0, 0, AMUN, 0, AMUF]; rows 1..=NE = (E,
2314        // D, GX, GN, GG, GF). Total = 6*(NE+1) = 18 floats = 3 lines.
2315        const ENDF: &str =
2316            include_str!("../../../tests/data/synthetic/lrf2_urr_int3_roundtrip.endf");
2317
2318        let data = parse_endf_file2(ENDF).expect("LRF=2 URR with INT=3 must parse");
2319        assert_eq!(data.ranges.len(), 1, "must have one URR range");
2320        let urr = data.ranges[0]
2321            .urr
2322            .as_ref()
2323            .expect("URR data must be present");
2324        assert_eq!(urr.lrf, 2);
2325        assert_eq!(urr.l_groups.len(), 1);
2326        let jg = &urr.l_groups[0].j_groups[0];
2327        assert_eq!(jg.int_code, 3, "INT code must round-trip as 3");
2328        assert_eq!(jg.energies.len(), 2);
2329        assert!((jg.energies[0] - 1e3).abs() < 1e-6);
2330        assert!((jg.energies[1] - 1e5).abs() < 1e-3);
2331        assert!((jg.amun - 1.0).abs() < 1e-14);
2332        assert!((jg.gn[0] - 1e-3).abs() < 1e-14);
2333        assert!((jg.gn[1] - 2e-3).abs() < 1e-14);
2334    }
2335
2336    /// LRF=2 URR with INT=0 is rejected as a hard error.
2337    ///
2338    /// ENDF-6 §0.5 defines INT codes 1..=5 only. INT=0 is malformed,
2339    /// not merely unsupported, so the parser surfaces it as
2340    /// `UnsupportedFormat("INT=0 out of spec (expected 1..=5)")` rather
2341    /// than panicking or silently defaulting to lin-lin.
2342    #[test]
2343    fn test_parse_lrf2_urr_int0_rejected() {
2344        // Same skeleton as INT=3 test, but with INT=0 in the J CONT.
2345        const ENDF: &str =
2346            include_str!("../../../tests/data/synthetic/lrf2_urr_int0_rejected.endf");
2347
2348        let err = parse_endf_file2(ENDF).unwrap_err();
2349        // Match on the error variant structurally so the test only
2350        // depends on the parser surfacing `UnsupportedFormat`, not on
2351        // the exact `Display` wording of `EndfParseError`.  Then check
2352        // the payload string for the diagnostic substrings.
2353        match err {
2354            EndfParseError::UnsupportedFormat(ref msg) => {
2355                assert!(
2356                    msg.contains("INT=0") && msg.contains("out of spec"),
2357                    "expected INT-out-of-spec rejection in UnsupportedFormat payload, \
2358                     got: {msg}"
2359                );
2360            }
2361            other => panic!("expected EndfParseError::UnsupportedFormat for INT=0, got: {other:?}"),
2362        }
2363    }
2364
2365    // -----------------------------------------------------------------------
2366    // Issue #123: robustness tests for malformed input validation
2367    // -----------------------------------------------------------------------
2368
2369    /// `checked_count` rejects negative values.
2370    #[test]
2371    fn test_checked_count_negative() {
2372        let err = checked_count(-1, "NLS").unwrap_err();
2373        assert!(
2374            err.to_string().contains("Negative"),
2375            "expected negative error, got: {err}"
2376        );
2377    }
2378
2379    /// `checked_count` rejects values above `MAX_ENDF_COUNT` to prevent
2380    /// allocation bombs from malformed files.
2381    #[test]
2382    fn test_checked_count_upper_bound() {
2383        // Just above the limit.
2384        let err = checked_count(MAX_ENDF_COUNT + 1, "NRS").unwrap_err();
2385        assert!(
2386            err.to_string().contains("too large"),
2387            "expected upper-bound error, got: {err}"
2388        );
2389
2390        // At the limit: should succeed.
2391        assert_eq!(checked_count(MAX_ENDF_COUNT, "NRS").unwrap(), 1_000_000);
2392
2393        // i32::MAX: should be rejected.
2394        let err = checked_count(i32::MAX, "NPL").unwrap_err();
2395        assert!(
2396            err.to_string().contains("too large"),
2397            "expected upper-bound error for i32::MAX, got: {err}"
2398        );
2399    }
2400
2401    /// `parse_endf_int` rejects non-integral float values rather than
2402    /// silently truncating.
2403    ///
2404    /// Without the strict check, an INT-field value stored as
2405    /// `"1.900000+0"` would be cast as `1.9_f64 as i32 == 1`, masking
2406    /// a malformed evaluation.  After the strict check, the parser
2407    /// surfaces `InvalidNumber` immediately.
2408    ///
2409    /// The two field layouts:
2410    ///   • field 0: integral float "1.000000+0" → returns Ok(1)
2411    ///   • field 1: non-integral  "1.900000+0" → returns Err(InvalidNumber)
2412    /// must both round-trip the standard ENDF integer-as-float encoding.
2413    #[test]
2414    fn test_parse_endf_int_rejects_non_integral_float() {
2415        // 11-char fields, padded with leading space to match the ENDF column
2416        // width.  Field 0 is integral; field 1 is non-integral.
2417        const LINE: &str = " 1.000000+0 1.900000+0";
2418
2419        // Integral float must parse cleanly.
2420        let ok = parse_endf_int(LINE, 0).expect("integral float must parse");
2421        assert_eq!(ok, 1);
2422
2423        // Non-integral float must be rejected, not truncated to 1.
2424        let err = parse_endf_int(LINE, 1).expect_err("non-integral must be rejected");
2425        match err {
2426            EndfParseError::InvalidNumber(ref msg) => {
2427                assert!(
2428                    msg.contains("Non-integral"),
2429                    "expected Non-integral diagnostic, got: {msg}"
2430                );
2431            }
2432            other => panic!("expected InvalidNumber, got: {other:?}"),
2433        }
2434    }
2435
2436    /// Negative L-value in a Breit-Wigner range is rejected.
2437    ///
2438    /// Constructs a minimal SLBW fixture with L=-1 in the L-group CONT record.
2439    /// Without the validation, `l_cont.l1 as u32` would wrap to `u32::MAX`.
2440    #[test]
2441    fn test_bw_negative_l_rejected() {
2442        // Minimal SLBW fixture: HEAD + isotope CONT + range CONT + SPI/AP CONT +
2443        // L-group CONT with L=-1 (field 3 = -1).
2444        const ENDF: &str =
2445            include_str!("../../../tests/data/synthetic/lrf1_bw_negative_l_rejected.endf");
2446
2447        let err = parse_endf_file2(ENDF).unwrap_err();
2448        assert!(
2449            err.to_string().contains("negative L"),
2450            "expected negative L error, got: {err}"
2451        );
2452    }
2453
2454    /// Negative L-value in a Reich-Moore range is rejected.
2455    #[test]
2456    fn test_rm_negative_l_rejected() {
2457        const ENDF: &str =
2458            include_str!("../../../tests/data/synthetic/lrf3_rm_negative_l_rejected.endf");
2459
2460        let err = parse_endf_file2(ENDF).unwrap_err();
2461        assert!(
2462            err.to_string().contains("negative L"),
2463            "expected negative L error, got: {err}"
2464        );
2465    }
2466
2467    /// LRU=0 range with non-zero NLS is rejected.
2468    ///
2469    /// ENDF-6 §2.2 says the SPI/AP CONT after an LRU=0 range must have
2470    /// NLS=0 (no L-groups for scattering-radius-only ranges).
2471    #[test]
2472    fn test_lru0_nonzero_nls_rejected() {
2473        const ENDF: &str =
2474            include_str!("../../../tests/data/synthetic/lru0_nonzero_nls_rejected.endf");
2475
2476        let err = parse_endf_file2(ENDF).unwrap_err();
2477        assert!(
2478            err.to_string().contains("NLS=3"),
2479            "expected LRU=0 NLS validation error, got: {err}"
2480        );
2481    }
2482
2483    /// LFW=1/LRF=1 (energy-dependent fission widths) URR is fully parsed.
2484    ///
2485    /// ENDF-6 §2.2.2.1 Case B: a shared NE-point energy grid is followed,
2486    /// for each (L, J), by a full LIST record — a control line
2487    /// `[0.0, 0.0, L, MUF, NE+6, 0]` and then a body
2488    /// `[D, AJ, AMUN, GNO, GG, 0] + GF(1..NE)`.  The per-J control line MUST
2489    /// be consumed before the body; otherwise the line stream misaligns by
2490    /// one record per J-group and the wrong values are read.
2491    ///
2492    /// This fixture is standards-compliant (it includes the per-J control
2493    /// line), so it fails against a parser that omits that read: the body
2494    /// `[D, AJ, ...]` would be misread from the control line, yielding
2495    /// `AJ=0` and `GF=[10, 3]` instead of the values asserted below.
2496    ///
2497    /// The fixture has NE=2, NLS=1, NJS=1, MUF=1.
2498    #[test]
2499    fn test_lfw1_lrf1_urr_fully_parsed() {
2500        const ENDF: &str =
2501            include_str!("../../../tests/data/synthetic/lfw1_urr_gracefully_skipped.endf");
2502
2503        let data = parse_endf_file2(ENDF).expect("LFW=1 URR parse should succeed");
2504        // LFW=1/LRF=1 is fully parsed — URR data should be present.
2505        let urr_count = data.ranges.iter().filter(|r| r.urr.is_some()).count();
2506        assert_eq!(urr_count, 1, "LFW=1/LRF=1 URR should be parsed");
2507        let urr = data.ranges.iter().find(|r| r.urr.is_some()).unwrap();
2508        let urr_data = urr.urr.as_ref().unwrap();
2509        assert_eq!(urr_data.lrf, 1);
2510        assert_eq!(urr_data.l_groups.len(), 1);
2511        assert_eq!(urr_data.l_groups[0].l, 0, "L-value");
2512        assert_eq!(urr_data.l_groups[0].j_groups.len(), 1, "one J-group");
2513
2514        let jg = &urr_data.l_groups[0].j_groups[0];
2515        // Scalar (energy-independent) parameters from the LIST body row 0.
2516        assert!((jg.j - 3.0).abs() < 1e-6, "AJ = {}", jg.j);
2517        assert!((jg.amun - 1.0).abs() < 1e-6, "AMUN = {}", jg.amun);
2518        // MUF (fission degrees of freedom) comes from the per-J control L2.
2519        assert!((jg.amuf - 1.0).abs() < 1e-6, "AMUF (MUF) = {}", jg.amuf);
2520        assert_eq!(jg.d.len(), 1);
2521        assert!((jg.d[0] - 10.0).abs() < 1e-6, "D = {}", jg.d[0]);
2522        assert_eq!(jg.gn.len(), 1);
2523        assert!((jg.gn[0] - 0.05).abs() < 1e-6, "GNO = {}", jg.gn[0]);
2524        assert_eq!(jg.gg.len(), 1);
2525        assert!((jg.gg[0] - 0.04).abs() < 1e-6, "GG = {}", jg.gg[0]);
2526
2527        // Fission widths are energy-dependent (NE=2 values on the shared grid).
2528        assert_eq!(jg.energies.len(), 2, "shared energy grid has NE points");
2529        assert!(
2530            (jg.energies[0] - 600.0).abs() < 1e-3,
2531            "E[0] = {}",
2532            jg.energies[0]
2533        );
2534        assert!(
2535            (jg.energies[1] - 30000.0).abs() < 1e-3,
2536            "E[1] = {}",
2537            jg.energies[1]
2538        );
2539        assert_eq!(jg.gf.len(), 2, "LFW=1 should have NE fission widths");
2540        assert!((jg.gf[0] - 0.1).abs() < 1e-6, "GF[0] = {}", jg.gf[0]);
2541        assert!((jg.gf[1] - 0.2).abs() < 1e-6, "GF[1] = {}", jg.gf[1]);
2542    }
2543
2544    /// A per-J LIST control whose `N1 != NE+6` is a malformed Case-B record.
2545    /// The parser must reject it — this covers the per-J N1 validation guard
2546    /// (the SCALE `list.getN1()-6 == ener.getNtot()` relation). The fixture is
2547    /// byte-identical to the valid one except the per-J control N1 (8 -> 7).
2548    #[test]
2549    fn test_lfw1_lrf1_urr_rejects_bad_perj_n1() {
2550        const ENDF: &str = include_str!("../../../tests/data/synthetic/lfw1_urr_bad_perj_n1.endf");
2551
2552        let err = parse_endf_file2(ENDF).unwrap_err();
2553        assert!(
2554            err.to_string().contains("N1=") && err.to_string().contains("NE+6"),
2555            "expected per-J N1 != NE+6 rejection, got: {err}"
2556        );
2557    }
2558
2559    /// LRU=0 range with non-zero L1 in SPI/AP CONT is rejected.
2560    ///
2561    /// ENDF-6 §2.2: the SPI/AP CONT record for LRU=0 must be
2562    /// [SPI, AP, 0, 0, NLS=0, 0].  Non-zero L1 or L2 indicates a
2563    /// malformed or mis-identified record.
2564    #[test]
2565    fn test_lru0_nonzero_l1_rejected() {
2566        const ENDF: &str =
2567            include_str!("../../../tests/data/synthetic/lru0_nonzero_l1_rejected.endf");
2568
2569        let err = parse_endf_file2(ENDF).unwrap_err();
2570        assert!(
2571            err.to_string().contains("L1=5"),
2572            "expected LRU=0 L1 validation error, got: {err}"
2573        );
2574    }
2575
2576    /// N1 != 6*NRS in a BW range CONT is rejected.
2577    #[test]
2578    fn test_bw_n1_nrs_mismatch_rejected() {
2579        const ENDF: &str =
2580            include_str!("../../../tests/data/synthetic/lrf1_bw_n1_nrs_mismatch_rejected.endf");
2581
2582        let err = parse_endf_file2(ENDF).unwrap_err();
2583        assert!(
2584            err.to_string().contains("N1=7"),
2585            "expected N1/NRS mismatch error, got: {err}"
2586        );
2587    }
2588
2589    /// Multi-MAT detection: unconsumed MF=2/MT=151 lines after the first
2590    /// material are rejected.
2591    #[test]
2592    fn test_multi_mat_detection() {
2593        // A valid single-range SLBW file with an extra trailing data line
2594        // that still carries MF=2/MT=151 tags.
2595        const ENDF: &str = include_str!("../../../tests/data/synthetic/multi_mat_detection.endf");
2596
2597        let err = parse_endf_file2(ENDF).unwrap_err();
2598        assert!(
2599            err.to_string().contains("Multiple materials"),
2600            "expected multi-MAT error, got: {err}"
2601        );
2602    }
2603
2604    /// Verify URR energy deduplication keeps the last occurrence.
2605    #[test]
2606    #[allow(clippy::useless_vec)] // Vecs needed for mutation (truncate/copy_within)
2607    fn test_urr_energy_dedup_keeps_last() {
2608        // Simulate the dedup logic on mock parallel arrays.
2609        let mut energies = vec![1.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0];
2610        let mut d = vec![10.0, 20.0, 21.0, 30.0, 31.0, 32.0, 40.0];
2611        let mut gx = vec![0.0; 7];
2612        let mut gn = vec![0.0; 7];
2613        let mut gg = vec![0.0; 7];
2614        let mut gf = vec![0.0; 7];
2615
2616        let n = energies.len();
2617        if n > 1 {
2618            let mut write = n - 1;
2619            let mut last_e = energies[n - 1];
2620            let mut read = n - 1;
2621            while read > 0 {
2622                read -= 1;
2623                if energies[read] == last_e {
2624                    continue;
2625                }
2626                write -= 1;
2627                energies[write] = energies[read];
2628                d[write] = d[read];
2629                gx[write] = gx[read];
2630                gn[write] = gn[read];
2631                gg[write] = gg[read];
2632                gf[write] = gf[read];
2633                last_e = energies[read];
2634            }
2635            let new_len = n - write;
2636            energies.copy_within(write..n, 0);
2637            d.copy_within(write..n, 0);
2638            energies.truncate(new_len);
2639            d.truncate(new_len);
2640        }
2641
2642        assert_eq!(energies, [1.0, 2.0, 3.0, 4.0]);
2643        // d[1]=21.0 (last of the 2.0 pair), d[2]=32.0 (last of the 3.0 triple)
2644        assert_eq!(d, [10.0, 21.0, 32.0, 40.0]);
2645    }
2646
2647    /// MF=2 NIS>1 multi-isotope materials are rejected.
2648    ///
2649    /// ENDF-6 §2.1 allows a single material to carry several isotopes, each
2650    /// with its own ZAI/ABN/NER subsection. NEREIDS's `ResonanceData` cannot
2651    /// represent that hierarchy without losing per-isotope abundance weights;
2652    /// rather than silently flatten the ranges into one isotope, the parser
2653    /// returns `UnsupportedFormat` so a downstream consumer cannot be tricked
2654    /// into computing an abundance-blind cross section.
2655    ///
2656    /// The synthetic fixture is a minimal two-isotope SLBW material: Cu-63
2657    /// (ZAI=29063, ABN=0.6917) and Cu-65 (ZAI=29065, ABN=0.3083), each with
2658    /// a single L=0 resonance. The parser should reject the file as soon as
2659    /// it reads the HEAD record (NIS=2 > 1) and never advance into either
2660    /// isotope subsection.
2661    #[test]
2662    fn test_parse_endf_rejects_nis_gt_1() {
2663        // Minimal NIS=2 fixture. Both isotope subsections use LRF=1 SLBW so
2664        // that, if the NIS guard were ever removed, the parser would have a
2665        // valid stream to walk and the assertion below would still pin the
2666        // guard. The HEAD's ZA is set to the natural-element identifier
2667        // ZA=29000 (nat-Cu); for NIS=1 callers that would be rejected by
2668        // `isotope_from_za` with an "A=0" error, but the NIS=2 check runs
2669        // first and returns the expected UnsupportedFormat.
2670        const ENDF: &str = include_str!("../../../tests/data/synthetic/nis_gt_1_rejected.endf");
2671
2672        let err = parse_endf_file2(ENDF).unwrap_err();
2673        match &err {
2674            EndfParseError::UnsupportedFormat(msg) => {
2675                assert!(
2676                    msg.contains("NIS=2"),
2677                    "expected NIS=2 in error message, got: {msg}"
2678                );
2679                assert!(
2680                    msg.contains("multi-isotope"),
2681                    "expected 'multi-isotope' in error message, got: {msg}"
2682                );
2683            }
2684            other => panic!("expected UnsupportedFormat for NIS>1, got {other:?}"),
2685        }
2686    }
2687
2688    /// LRF=7 resonance LIST carries NRS in L2 and NX (packed-row count) in N2.
2689    ///
2690    /// ENDF-6 §2.2.1.6: the resonance LIST control record is
2691    /// `[C1=0, C2=0, L1=0, L2=NRS, N1=6*NX, N2=NX]`. For spin groups whose
2692    /// per-resonance row fits in one 6-float ENDF line (NCH+1 ≤ 6 for KRM=2,
2693    /// NCH+2 ≤ 6 for KRM=3), NX numerically equals NRS, so the field
2694    /// confusion is invisible. This fixture stresses the case where the
2695    /// per-resonance row requires *more than one* 6-float row, giving
2696    /// NX > NRS and L2 ≠ N2.
2697    ///
2698    /// Construction: KRM=3, NCH=5 (5 elastic channels in a single spin
2699    /// group), NRS=2.
2700    ///   per-resonance values = NCH+2 = 7 → 2 packed rows of 6 floats (12
2701    ///     values per resonance, last 5 are padding zeros).
2702    ///   NX = NRS · 2 = 4 packed rows.
2703    ///   NPL = 6·NX = 24 floats.
2704    ///   resonance LIST control = `[0.0, 0.0, 0, NRS=2, 24, NX=4]`.
2705    ///
2706    /// Under the (pre-fix) buggy reader that took NRS from N2, this fixture
2707    /// would set NRS=4, recompute stride = NPL/NRS = 6, and trip the
2708    /// min_stride guard (6 < NCH+2 = 7) with a misleading
2709    /// "stride too small" UnsupportedFormat error. With the fix it reads
2710    /// NRS=2 (from L2) and stride = NPL/NRS = 12 = 2·6, parses exactly
2711    /// two resonances at the intended energies, and validates that
2712    /// each resonance carries 5 partial widths.
2713    #[test]
2714    fn test_parse_lrf7_l2_holds_nrs_with_nx_neq_nrs() {
2715        const ENDF: &str =
2716            include_str!("../../../tests/data/synthetic/lrf7_l2_holds_nrs_with_nx_neq_nrs.endf");
2717
2718        let data = parse_endf_file2(ENDF).expect(
2719            "LRF=7 fixture with NX != NRS must parse without error after the NRS-from-L2 fix",
2720        );
2721        let rml = data.ranges[0]
2722            .rml
2723            .as_ref()
2724            .expect("LRF=7 range must have RmlData");
2725        let sg = &rml.spin_groups[0];
2726
2727        assert_eq!(
2728            sg.resonances.len(),
2729            2,
2730            "must parse exactly NRS=2 resonances (from L2), not NX=4 (from N2)"
2731        );
2732        assert_eq!(sg.channels.len(), 5, "NCH must be 5");
2733        assert_eq!(
2734            sg.resonances[0].widths.len(),
2735            5,
2736            "each resonance carries NCH=5 partial widths"
2737        );
2738
2739        let res0 = &sg.resonances[0];
2740        assert!(
2741            (res0.energy - 10.0).abs() < 1e-10,
2742            "res0 energy must be 10.0 eV, got {}",
2743            res0.energy
2744        );
2745        assert!(
2746            (res0.gamma_gamma - 0.025).abs() < 1e-10,
2747            "res0 gamma_gamma must be 0.025 eV (KRM=3, at b+1), got {}",
2748            res0.gamma_gamma
2749        );
2750        assert!(
2751            (res0.widths[4] - 0.005).abs() < 1e-10,
2752            "res0 widths[4] must be 0.005 (last channel of multi-row resonance), got {}",
2753            res0.widths[4]
2754        );
2755
2756        let res1 = &sg.resonances[1];
2757        assert!(
2758            (res1.energy - 20.0).abs() < 1e-10,
2759            "res1 energy must be 20.0 eV, got {}",
2760            res1.energy
2761        );
2762        assert!(
2763            (res1.widths[4] - 0.050).abs() < 1e-10,
2764            "res1 widths[4] must be 0.050, got {}",
2765            res1.widths[4]
2766        );
2767    }
2768
2769    /// LRF=7 spin group with nonzero KBK (R-external background) is rejected.
2770    ///
2771    /// The ENDF-6 §2.2.1.6 manual prose treats KBK as a nonzero flag with NCH
2772    /// background records; OpenScale's reference reader
2773    /// (File2.cpp:444-524) treats KBK as a sparse record count with each
2774    /// subrecord carrying the channel index in L1 and the LBK formalism flag
2775    /// in L2. The two conventions disagree on loop bound, per-subrecord
2776    /// control-field positions, and payload shape per LBK value. No local
2777    /// ENDF/B-VIII.0 evaluation has nonzero KBK to validate against.
2778    /// Until a policy decision resolves the dispute, NEREIDS hard-rejects
2779    /// nonzero KBK so the parser cannot silently misalign the stream past
2780    /// the offending spin group.
2781    #[test]
2782    fn test_parse_lrf7_rejects_nonzero_kbk() {
2783        const ENDF: &str =
2784            include_str!("../../../tests/data/synthetic/lrf7_nonzero_kbk_rejected.endf");
2785
2786        let err = parse_endf_file2(ENDF).unwrap_err();
2787        match &err {
2788            EndfParseError::UnsupportedFormat(msg) => {
2789                assert!(
2790                    msg.contains("KBK=1"),
2791                    "expected KBK=1 in error message, got: {msg}"
2792                );
2793            }
2794            other => panic!("expected UnsupportedFormat for KBK != 0, got {other:?}"),
2795        }
2796    }
2797
2798    /// LRF=7 spin group with nonzero KPS (tabulated phase-shift override) is
2799    /// rejected. Same documentation-vs-implementation dispute as KBK;
2800    /// OpenScale itself refuses to read KPS > 0
2801    /// (File2.cpp:439-441 throws "kps > 0 for lrf=7 not yet supported"), so
2802    /// NEREIDS adopts the same behaviour rather than guess at a layout.
2803    #[test]
2804    fn test_parse_lrf7_rejects_nonzero_kps() {
2805        const ENDF: &str =
2806            include_str!("../../../tests/data/synthetic/lrf7_nonzero_kps_rejected.endf");
2807
2808        let err = parse_endf_file2(ENDF).unwrap_err();
2809        match &err {
2810            EndfParseError::UnsupportedFormat(msg) => {
2811                assert!(
2812                    msg.contains("KPS=1"),
2813                    "expected KPS=1 in error message, got: {msg}"
2814                );
2815            }
2816            other => panic!("expected UnsupportedFormat for KPS != 0, got {other:?}"),
2817        }
2818    }
2819
2820    /// LRF=7 particle pair with PNT=2 ("ASSIGN") is rejected at parse time.
2821    /// SAMMY's Check_Quantum (rml/mrml03.f:22) rejects Lpent outside {0,1};
2822    /// neither SAMMY nor NEREIDS implements PNT=2.  Validating up front keeps
2823    /// the unknown penetrability flag out of the physics evaluator.
2824    #[test]
2825    fn test_parse_lrf7_rejects_pnt_two() {
2826        const ENDF: &str = include_str!("../../../tests/data/synthetic/lrf7_pnt2_rejected.endf");
2827
2828        let err = parse_endf_file2(ENDF).unwrap_err();
2829        match &err {
2830            EndfParseError::UnsupportedFormat(msg) => {
2831                assert!(
2832                    msg.contains("PNT=2"),
2833                    "expected PNT=2 in error message, got: {msg}"
2834                );
2835            }
2836            other => panic!("expected UnsupportedFormat for PNT=2, got {other:?}"),
2837        }
2838    }
2839
2840    /// LRF=7 massless particle pair (MA=0, photon/eliminated channel) declared
2841    /// with PNT=1 is rejected: SAMMY always assigns Lpent=0 to the photon
2842    /// channel, and the physics evaluator's PNT=1 penetrability branch would
2843    /// otherwise divide by a zero reduced mass.
2844    #[test]
2845    fn test_parse_lrf7_rejects_massless_pnt_one() {
2846        const ENDF: &str =
2847            include_str!("../../../tests/data/synthetic/lrf7_massless_pnt1_rejected.endf");
2848
2849        let err = parse_endf_file2(ENDF).unwrap_err();
2850        match &err {
2851            EndfParseError::UnsupportedFormat(msg) => {
2852                assert!(
2853                    msg.contains("must have PNT=0"),
2854                    "expected massless-PNT consistency message, got: {msg}"
2855                );
2856            }
2857            other => panic!("expected UnsupportedFormat for massless PNT=1, got {other:?}"),
2858        }
2859    }
2860
2861    /// LRF=7 particle pair with a fractional PNT (1.5) is rejected before the
2862    /// f64→i32 narrowing can truncate it into a spurious 0/1 that would bypass
2863    /// the {0,1} range check.
2864    #[test]
2865    fn test_parse_lrf7_rejects_fractional_pnt() {
2866        const ENDF: &str =
2867            include_str!("../../../tests/data/synthetic/lrf7_pnt_fractional_rejected.endf");
2868
2869        let err = parse_endf_file2(ENDF).unwrap_err();
2870        match &err {
2871            EndfParseError::UnsupportedFormat(msg) => {
2872                assert!(
2873                    msg.contains("PNT=1.5") && msg.contains("not a finite integer"),
2874                    "expected fractional-PNT message, got: {msg}"
2875                );
2876            }
2877            other => panic!("expected UnsupportedFormat for fractional PNT, got {other:?}"),
2878        }
2879    }
2880
2881    /// LRF=7 PNT=1 pair with a non-positive mass (MB=0) is rejected up front:
2882    /// the penetrability path would otherwise form a non-finite reduced mass.
2883    #[test]
2884    fn test_parse_lrf7_rejects_pnt1_zero_mass() {
2885        const ENDF: &str =
2886            include_str!("../../../tests/data/synthetic/lrf7_pnt1_zero_mass_rejected.endf");
2887
2888        let err = parse_endf_file2(ENDF).unwrap_err();
2889        match &err {
2890            EndfParseError::UnsupportedFormat(msg) => {
2891                assert!(
2892                    msg.contains("finite positive masses"),
2893                    "expected PNT=1 mass-validation message, got: {msg}"
2894                );
2895            }
2896            other => panic!("expected UnsupportedFormat for PNT=1 zero mass, got {other:?}"),
2897        }
2898    }
2899
2900    /// MF=2 NIS=0 (no isotopes declared) is rejected up-front.
2901    ///
2902    /// ENDF-6 §2.1 requires NIS >= 1 for a valid resonance evaluation.
2903    /// Without the explicit reject, NIS=0 would fall through the per-isotope
2904    /// loop (zero iterations), leave the resonance section empty, and trip a
2905    /// confusing downstream "unconsumed data lines" / empty-range failure
2906    /// far from the actual root cause. The reject mirrors the NIS>1 guard
2907    /// pattern so both invalid extremes return a clear UnsupportedFormat.
2908    #[test]
2909    fn test_parse_endf_rejects_nis_zero() {
2910        // Minimal NIS=0 fixture: just the HEAD line with NIS=0. The HEAD's
2911        // ZA=74184 (W-184) is a valid identifier, so any error must come
2912        // from the NIS=0 guard, not from `isotope_from_za`.
2913        // HEAD: ZA=74184, AWR=182, NIS=0
2914        const ENDF: &str = include_str!("../../../tests/data/synthetic/nis_zero_rejected.endf");
2915
2916        let err = parse_endf_file2(ENDF).unwrap_err();
2917        match &err {
2918            EndfParseError::UnsupportedFormat(msg) => {
2919                assert!(
2920                    msg.contains("NIS=0"),
2921                    "expected NIS=0 in error message, got: {msg}"
2922                );
2923                assert!(
2924                    msg.contains("NIS >= 1"),
2925                    "expected 'NIS >= 1' guidance in error message, got: {msg}"
2926                );
2927            }
2928            other => panic!("expected UnsupportedFormat for NIS=0, got {other:?}"),
2929        }
2930    }
2931
2932    /// LRF=7 resonance LIST with N2/NX not divisible by L2/NRS is rejected.
2933    ///
2934    /// ENDF-6 §2.2.1.6 fixes NX = NRS · ceil(stride/6) where stride is NCH+1
2935    /// for KRM=2 and NCH+2 for KRM=3, so NX must be an integer multiple of
2936    /// NRS (the per-resonance packed-row count is constant within a spin
2937    /// group). A fixture with NRS=4 and NX=2 yields a fractional stride
2938    /// 6·NX/NRS = 3 floats per resonance, which would mis-align the
2939    /// resonance reads. Without the divisibility check, the existing
2940    /// `res_npl == 6*nx` guard passes (12 == 6·2) and the downstream
2941    /// `res_npl % nrs != 0` would also pass (12 % 4 == 0), producing the
2942    /// bogus stride. The new guard catches this directly.
2943    #[test]
2944    fn test_parse_lrf7_rejects_nx_not_multiple_of_nrs() {
2945        const ENDF: &str =
2946            include_str!("../../../tests/data/synthetic/lrf7_nx_not_multiple_of_nrs_rejected.endf");
2947
2948        let err = parse_endf_file2(ENDF).unwrap_err();
2949        match &err {
2950            EndfParseError::UnsupportedFormat(msg) => {
2951                assert!(
2952                    msg.contains("not a multiple"),
2953                    "expected 'not a multiple' in error message, got: {msg}"
2954                );
2955                assert!(
2956                    msg.contains("NX (2)") || msg.contains("(2)"),
2957                    "expected NX=2 to appear in error message, got: {msg}"
2958                );
2959                assert!(
2960                    msg.contains("NRS (4)") || msg.contains("(4)"),
2961                    "expected NRS=4 to appear in error message, got: {msg}"
2962                );
2963            }
2964            other => {
2965                panic!("expected UnsupportedFormat for NX not multiple of NRS, got {other:?}")
2966            }
2967        }
2968    }
2969
2970    /// LRF=7 spin group with zero resonances must be accepted when written in
2971    /// the canonical ENDF-6 §2.2.1.6 form NRS=0, NX=1, NPL=6 (a single
2972    /// six-float zero-filler row in the LIST body).
2973    ///
2974    /// OpenScale's reference writer at
2975    /// `external/openScale/repo/packages/ScaleUtils/EndfLib/endf/File2.cpp:683-697`
2976    /// pads the resonance LIST for empty spin groups:
2977    ///
2978    /// ```cpp
2979    /// list.setL2(spin->getNres());        // L2 = NRS = 0
2980    /// ...
2981    /// // nx must be at least 1, even if nres=0
2982    /// if (spin->getNres() == 0)
2983    ///     nx = 1;
2984    /// list.setN1(6 * nx);                  // N1 = 6
2985    /// list.setN2(nx);                      // N2 = 1
2986    /// ```
2987    ///
2988    /// A naive guard `if nrs == 0 && nx != 0 { reject }` would reject this
2989    /// canonical pattern (NX=1 ≠ 0). The relaxed guard
2990    /// `if nrs == 0 && nx != 1 { reject }` accepts it while still rejecting
2991    /// malformed shapes such as NRS=0/NX=2.
2992    #[test]
2993    fn test_parse_lrf7_accepts_nrs_zero_nx_one_canonical_empty() {
2994        const ENDF: &str =
2995            include_str!("../../../tests/data/synthetic/lrf7_nrs_zero_nx_one_canonical_empty.endf");
2996
2997        let data = parse_endf_file2(ENDF)
2998            .expect("LRF=7 fixture with NRS=0/NX=1 canonical empty spin group must parse cleanly");
2999        let rml = data.ranges[0]
3000            .rml
3001            .as_ref()
3002            .expect("LRF=7 range must have RmlData");
3003        assert_eq!(
3004            rml.spin_groups.len(),
3005            1,
3006            "must parse exactly one spin group"
3007        );
3008        let sg = &rml.spin_groups[0];
3009        assert!(
3010            sg.resonances.is_empty(),
3011            "empty spin group must contain zero resonances, got {}",
3012            sg.resonances.len()
3013        );
3014        assert_eq!(
3015            sg.channels.len(),
3016            1,
3017            "empty spin group still carries its NCH channel definitions"
3018        );
3019    }
3020
3021    /// LRF=7 spin group with NRS=0 but NX≠1 is rejected as malformed.
3022    ///
3023    /// OpenScale's writer (File2.cpp:683-697) explicitly pads NX to 1 when
3024    /// NRS=0, so any NRS=0 record with NX=0 (no filler row) or NX>1 (phantom
3025    /// filler rows with nothing to anchor them) is not a valid ENDF-6 emission.
3026    /// The previous over-permissive guard accepted NRS=0/NX=2 silently,
3027    /// leaving the parser to read two zero-filled rows as "no resonances"
3028    /// while the LIST body did contain data that some other reader might
3029    /// interpret as resonance parameters.
3030    #[test]
3031    fn test_parse_lrf7_rejects_nrs_zero_nx_two_malformed() {
3032        const ENDF: &str =
3033            include_str!("../../../tests/data/synthetic/lrf7_nrs_zero_nx_two_malformed.endf");
3034
3035        let err = parse_endf_file2(ENDF).unwrap_err();
3036        match &err {
3037            EndfParseError::UnsupportedFormat(msg) => {
3038                assert!(
3039                    msg.contains("NRS=0"),
3040                    "expected 'NRS=0' in error message, got: {msg}"
3041                );
3042                assert!(
3043                    msg.contains("NX=2"),
3044                    "expected 'NX=2' in error message, got: {msg}"
3045                );
3046            }
3047            other => panic!("expected UnsupportedFormat for NRS=0/NX!=1, got {other:?}"),
3048        }
3049    }
3050}