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    let isotope = isotope_from_za(za)?;
75    let mut all_ranges = Vec::new();
76
77    for _ in 0..nis {
78        // Isotope CONT: ZAI, ABN, 0, LFW, NER, 0
79        let iso_cont = parse_cont(&lines, &mut pos)?;
80        let _zai = iso_cont.c1 as u32;
81        let _abn = iso_cont.c2; // abundance
82        let lfw = iso_cont.l2; // fission width flag (LFW=1 → energy-dependent fission widths in URR)
83        let ner = checked_count(iso_cont.n1, "NER")?; // number of energy ranges
84
85        for _ in 0..ner {
86            // Range CONT: EL, EH, LRU, LRF, NRO, NAPS
87            let range_cont = parse_cont(&lines, &mut pos)?;
88            let energy_low = range_cont.c1;
89            let energy_high = range_cont.c2;
90            let lru = range_cont.l1; // 1=resolved, 2=unresolved
91            let lrf = range_cont.l2; // resonance formalism
92
93            if lru == 2 {
94                // Unresolved resonance region (LRU=2).
95                // URR uses average level-spacing/width parameters; cross-sections are
96                // computed via Hauser-Feshbach in nereids_physics::urr.
97                //
98                // Unsupported sub-formats are skipped gracefully so that the resolved
99                // resonance ranges in the same evaluation remain accessible.
100                // Hard errors are reserved for genuinely malformed records.
101
102                // NRO=range_cont.n1: if non-zero a TAB1 AP(E) record immediately follows
103                // the range CONT before the URR SPI/AP/NLS CONT.
104                // ENDF-6 §2.2.2; SAMMY unr/munr01.f90.
105                let nro_urr = range_cont.n1;
106                let naps_urr = range_cont.n2; // scattering radius calculation flag
107                let ap_table_urr = if nro_urr != 0 {
108                    let mut tab = parse_tab1(&lines, &mut pos)?;
109                    // AP(E) y-values are in 10⁻¹² cm; convert to fm.
110                    for pt in &mut tab.points {
111                        pt.1 *= ENDF_RADIUS_TO_FM;
112                    }
113                    Some(tab)
114                } else {
115                    None
116                };
117
118                // LRF=1 and LRF=2 with LFW=0 are fully supported.
119                // Unsupported combinations are skipped so that the resolved
120                // resonance ranges in the same evaluation remain accessible.
121                if lrf != 1 && lrf != 2 {
122                    skip_urr_body(&lines, &mut pos)?;
123                    continue;
124                }
125
126                // LFW=1 (energy-dependent fission widths):
127                // LRF=2: record layout identical to LFW=0 — handled below.
128                // LRF=1: different record layout (shared energy LIST).
129                // Reference: ENDF-6 §2.2.2.1 Case B; SAMMY File2Unres.f90.
130                if lfw != 0 && lrf == 1 {
131                    let mut urr_ctx = RangeParseContext {
132                        lines: &lines,
133                        pos: &mut pos,
134                        energy_low,
135                        energy_high,
136                        naps: naps_urr,
137                        ap_table: ap_table_urr,
138                    };
139                    let urr_range = parse_urr_lfw1_lrf1(&mut urr_ctx)?;
140                    all_ranges.push(urr_range);
141                    continue;
142                }
143
144                let mut urr_ctx = RangeParseContext {
145                    lines: &lines,
146                    pos: &mut pos,
147                    energy_low,
148                    energy_high,
149                    naps: naps_urr,
150                    ap_table: ap_table_urr,
151                };
152                let urr_range = parse_urr_range(&mut urr_ctx, lrf)?;
153                all_ranges.push(urr_range);
154                continue;
155            }
156
157            if lru == 0 {
158                // LRU=0: scattering-radius-only range (no resonance parameters).
159                // ENDF-6 §2.2: after the range CONT (and optional TAB1 if NRO!=0),
160                // a single CONT record follows: [SPI, AP, 0, 0, NLS=0, 0].
161                // We consume the NRO TAB1 if present, then the CONT, and skip.
162                let nro_lru0 = range_cont.n1;
163                if nro_lru0 != 0 {
164                    // TAB1 AP(E) already follows; consume it.
165                    let _tab = parse_tab1(&lines, &mut pos)?;
166                }
167                // CONT: SPI, AP, 0, 0, NLS=0, 0
168                // Validate NLS=0 (#123): a non-zero NLS in an LRU=0 range is
169                // malformed and would cause the parser to look for L-groups that
170                // don't exist, misaligning the cursor for subsequent ranges.
171                let spi_cont = parse_cont(&lines, &mut pos)?;
172                // ENDF-6 §2.2: the SPI/AP CONT is [SPI, AP, 0, 0, NLS=0, 0].
173                // Validate that L1 and L2 are both zero — non-zero values
174                // indicate a malformed or mis-identified record.
175                if spi_cont.l1 != 0 {
176                    return Err(EndfParseError::UnsupportedFormat(format!(
177                        "LRU=0 range: L1={} in SPI/AP CONT record must be 0",
178                        spi_cont.l1
179                    )));
180                }
181                if spi_cont.l2 != 0 {
182                    return Err(EndfParseError::UnsupportedFormat(format!(
183                        "LRU=0 range: L2={} in SPI/AP CONT record must be 0",
184                        spi_cont.l2
185                    )));
186                }
187                if spi_cont.n1 != 0 {
188                    return Err(EndfParseError::UnsupportedFormat(format!(
189                        "LRU=0 range: NLS={} in SPI/AP CONT record must be 0 \
190                         (scattering-radius-only ranges have no L-groups)",
191                        spi_cont.n1
192                    )));
193                }
194                if spi_cont.n2 != 0 {
195                    return Err(EndfParseError::UnsupportedFormat(format!(
196                        "LRU=0 range: N2={} in SPI/AP CONT record must be 0",
197                        spi_cont.n2
198                    )));
199                }
200                continue;
201            }
202
203            if lru != 1 {
204                return Err(EndfParseError::UnsupportedFormat(format!(
205                    "LRU={} not supported (expected 0=scattering-radius-only, 1=resolved, or 2=unresolved)",
206                    lru
207                )));
208            }
209
210            let nro = range_cont.n1; // energy-dependent scattering radius flag
211            let naps = range_cont.n2; // scattering radius calculation flag
212
213            // If NRO != 0, a TAB1 record immediately follows giving AP(E).
214            // Parse and store it; scattering_radius_at(E) will interpolate it
215            // at each energy point.  Reference: ENDF-6 §2.2.1; SAMMY mlb/mmlb1.f90.
216            let ap_table = if nro != 0 {
217                let mut tab = parse_tab1(&lines, &mut pos)?;
218                // AP(E) y-values are in 10⁻¹² cm; convert to fm.
219                for pt in &mut tab.points {
220                    pt.1 *= ENDF_RADIUS_TO_FM;
221                }
222                Some(tab)
223            } else {
224                None
225            };
226
227            // ENDF-6 Formats Manual: LRF values for resolved resonance region
228            // LRF=1: Single-Level Breit-Wigner (SLBW)
229            // LRF=2: Multi-Level Breit-Wigner (MLBW)
230            // LRF=3: Reich-Moore
231            // LRF=4: Adler-Adler (deprecated, not supported)
232            // LRF=7: R-Matrix Limited (general)
233            let formalism = match lrf {
234                1 => ResonanceFormalism::SLBW,
235                2 => ResonanceFormalism::MLBW,
236                3 => ResonanceFormalism::ReichMoore,
237                7 => ResonanceFormalism::RMatrixLimited,
238                _ => {
239                    return Err(EndfParseError::UnsupportedFormat(format!(
240                        "LRF={} not yet supported",
241                        lrf
242                    )));
243                }
244            };
245
246            let mut ctx = RangeParseContext {
247                lines: &lines,
248                pos: &mut pos,
249                energy_low,
250                energy_high,
251                naps,
252                ap_table,
253            };
254            let range = match formalism {
255                ResonanceFormalism::MLBW | ResonanceFormalism::SLBW => {
256                    parse_bw_range(&mut ctx, formalism)?
257                }
258                ResonanceFormalism::ReichMoore => parse_reich_moore_range(&mut ctx)?,
259                ResonanceFormalism::RMatrixLimited => parse_rmatrix_limited_range(&mut ctx, awr)?,
260                ResonanceFormalism::Unresolved => {
261                    // Unreachable: Unresolved is only assigned in the LRU=2 branch above.
262                    unreachable!("Unresolved formalism should not appear in LRU=1 dispatch");
263                }
264            };
265            all_ranges.push(range);
266        }
267    }
268
269    // Multi-MAT detection (#114, #123): since `lines` is pre-filtered to
270    // MF=2/MT=151, any unconsumed lines are definitively from another material.
271    // The previous character-based heuristic for distinguishing "real data" from
272    // SEND/FEND/MEND/TEND records was overly complex — those section-end records
273    // use different MF/MT codes and are already excluded by the filter above.
274    //
275    // Assumption: trailing whitespace-only lines that happen to pass the MF/MT
276    // filter (i.e. have " 2" at cols 70-72 and "151" at cols 72-75) would also
277    // trigger this check.  In practice, ENDF files do not contain such lines —
278    // trailing blanks either lack the MF/MT fields entirely or use MF=0/MT=0,
279    // both of which are excluded by the filter in `parse_endf_file2`.
280    if pos < lines.len() {
281        return Err(EndfParseError::UnsupportedFormat(
282            "Multiple materials detected in MF=2/MT=151: unconsumed data lines \
283             remain after parsing the first material. Multi-MAT files are not \
284             supported; split the file into single-material ENDF files."
285                .to_string(),
286        ));
287    }
288
289    Ok(ResonanceData {
290        isotope,
291        za,
292        awr,
293        ranges: all_ranges,
294    })
295}
296
297/// Shared context for ENDF range parsers.
298///
299/// Groups the file-position state (`lines`, `pos`) with the fields from the
300/// range CONT record that every range parser needs, eliminating long argument
301/// lists.
302struct RangeParseContext<'a> {
303    lines: &'a [&'a str],
304    pos: &'a mut usize,
305    energy_low: f64,
306    energy_high: f64,
307    naps: i32,
308    ap_table: Option<Tab1>,
309}
310
311/// Parse a Breit-Wigner (SLBW or MLBW) resolved resonance range.
312///
313/// ENDF-6 File 2, LRF=1 (SLBW) / LRF=2 (MLBW):
314/// - CONT: SPI, AP, 0, 0, NLS, 0
315/// - For each L-value:
316///   - CONT: AWRI, 0.0, L, 0, 6*NRS, NRS
317///   - LIST: NRS resonances, each 6 values: ER, AJ, GT, GN, GG, GF
318///
319/// Reference: ENDF-6 Formats Manual Section 2.2.1.1
320fn parse_bw_range(
321    ctx: &mut RangeParseContext<'_>,
322    formalism: ResonanceFormalism,
323) -> Result<ResonanceRange, EndfParseError> {
324    // CONT: SPI, AP, 0, 0, NLS, 0
325    // ENDF AP is in 10⁻¹² cm; convert to fm (×10).
326    let cont = parse_cont(ctx.lines, ctx.pos)?;
327    let target_spin = cont.c1;
328    let scattering_radius = cont.c2 * ENDF_RADIUS_TO_FM;
329    let nls = checked_count(cont.n1, "NLS")?; // number of L-values
330
331    let mut l_groups = Vec::with_capacity(nls);
332
333    for _ in 0..nls {
334        // CONT: AWRI, QX, L, LRX, 6*NRS, NRS
335        let l_cont = parse_cont(ctx.lines, ctx.pos)?;
336        let awr_l = l_cont.c1;
337        let qx = l_cont.c2; // Q-value for competitive width (eV)
338        // Validate L is non-negative (#123): negative L1 wraps to a huge u32.
339        if l_cont.l1 < 0 {
340            return Err(EndfParseError::UnsupportedFormat(format!(
341                "BW range: negative L={}",
342                l_cont.l1
343            )));
344        }
345        let l_val = l_cont.l1 as u32;
346        let lrx = l_cont.l2; // competitive width flag
347        let n1 = checked_count(l_cont.n1, "N1")?; // should be 6*NRS
348        let nrs = checked_count(l_cont.n2, "NRS")?; // number of resonances
349
350        // Validate N1 == 6*NRS (#123): a mismatch means the record is malformed
351        // and reading N1 values would over-/under-consume lines.
352        if n1 != 6 * nrs {
353            return Err(EndfParseError::UnsupportedFormat(format!(
354                "BW range L={l_val}: N1={n1} != 6*NRS={} (NRS={nrs})",
355                6 * nrs
356            )));
357        }
358
359        let mut resonances = Vec::with_capacity(nrs);
360
361        // Each resonance is 6 values on one line (or spanning lines).
362        // In ENDF format, LIST records pack 6 values per line.
363        let total_values = nrs * 6;
364        let values = parse_list_values(ctx.lines, ctx.pos, total_values)?;
365
366        for i in 0..nrs {
367            let base = i * 6;
368            resonances.push(Resonance {
369                energy: values[base],  // ER
370                j: values[base + 1],   // AJ
371                gn: values[base + 3],  // GN (neutron width)
372                gg: values[base + 4],  // GG (gamma width)
373                gfa: values[base + 5], // GF (fission width)
374                gfb: 0.0,              // Not used in BW
375                                       // Note: values[base+2] is GT (total width) — derived, not stored
376            });
377        }
378
379        l_groups.push(LGroup {
380            l: l_val,
381            awr: awr_l,
382            apl: 0.0, // Not in BW format
383            qx,
384            lrx,
385            resonances,
386        });
387    }
388
389    Ok(ResonanceRange {
390        energy_low: ctx.energy_low,
391        energy_high: ctx.energy_high,
392        resolved: true,
393        formalism,
394        target_spin,
395        scattering_radius,
396        naps: ctx.naps,
397        ap_table: ctx.ap_table.take(),
398        l_groups,
399        rml: None,
400        urr: None,
401        r_external: vec![],
402    })
403}
404
405/// Parse a Reich-Moore resolved resonance range.
406///
407/// ENDF-6 File 2, LRF=2:
408/// ENDF-6 File 2, LRF=3 (Reich-Moore):
409/// - CONT: SPI, AP, 0, 0, NLS, 0
410/// - For each L-value:
411///   - CONT: AWRI, APL, L, 0, 6*NRS, NRS
412///   - LIST: NRS resonances, each 6 values: ER, AJ, GN, GG, GFA, GFB
413///
414/// Reference: ENDF-6 Formats Manual Section 2.2.1.3
415/// Reference: SAMMY manual Section 2 (R-matrix theory)
416fn parse_reich_moore_range(
417    ctx: &mut RangeParseContext<'_>,
418) -> Result<ResonanceRange, EndfParseError> {
419    // CONT: SPI, AP, 0, 0, NLS, 0
420    // ENDF AP is in 10⁻¹² cm; convert to fm (×10).
421    let cont = parse_cont(ctx.lines, ctx.pos)?;
422    let target_spin = cont.c1;
423    let scattering_radius = cont.c2 * ENDF_RADIUS_TO_FM;
424    let nls = checked_count(cont.n1, "NLS")?; // number of L-values
425
426    let mut l_groups = Vec::with_capacity(nls);
427
428    for _ in 0..nls {
429        // CONT: AWRI, APL, L, 0, 6*NRS, NRS
430        let l_cont = parse_cont(ctx.lines, ctx.pos)?;
431        let awr_l = l_cont.c1;
432        let apl = l_cont.c2 * ENDF_RADIUS_TO_FM; // L-dependent scattering radius
433        // Validate L is non-negative (#123): negative L1 wraps to a huge u32.
434        if l_cont.l1 < 0 {
435            return Err(EndfParseError::UnsupportedFormat(format!(
436                "Reich-Moore range: negative L={}",
437                l_cont.l1
438            )));
439        }
440        let l_val = l_cont.l1 as u32;
441        let n1 = checked_count(l_cont.n1, "N1")?; // should be 6*NRS
442        let nrs = checked_count(l_cont.n2, "NRS")?; // number of resonances
443
444        // Validate N1 == 6*NRS (#123): a mismatch means the record is malformed
445        // and reading N1 values would over-/under-consume lines.
446        if n1 != 6 * nrs {
447            return Err(EndfParseError::UnsupportedFormat(format!(
448                "Reich-Moore range L={l_val}: N1={n1} != 6*NRS={} (NRS={nrs})",
449                6 * nrs
450            )));
451        }
452
453        let mut resonances = Vec::with_capacity(nrs);
454
455        // Each resonance is 6 values: ER, AJ, GN, GG, GFA, GFB
456        let total_values = nrs * 6;
457        let values = parse_list_values(ctx.lines, ctx.pos, total_values)?;
458
459        for i in 0..nrs {
460            let base = i * 6;
461            resonances.push(Resonance {
462                energy: values[base],  // ER (eV)
463                j: values[base + 1],   // AJ (total J)
464                gn: values[base + 2],  // GN (neutron width, eV)
465                gg: values[base + 3],  // GG (gamma width, eV)
466                gfa: values[base + 4], // GFA (fission width 1, eV)
467                gfb: values[base + 5], // GFB (fission width 2, eV)
468            });
469        }
470
471        l_groups.push(LGroup {
472            l: l_val,
473            awr: awr_l,
474            apl,
475            qx: 0.0, // Not used in Reich-Moore
476            lrx: 0,  // Not used in Reich-Moore
477            resonances,
478        });
479    }
480
481    Ok(ResonanceRange {
482        energy_low: ctx.energy_low,
483        energy_high: ctx.energy_high,
484        resolved: true,
485        formalism: ResonanceFormalism::ReichMoore,
486        target_spin,
487        scattering_radius,
488        naps: ctx.naps,
489        ap_table: ctx.ap_table.take(),
490        l_groups,
491        rml: None,
492        urr: None,
493        r_external: vec![],
494    })
495}
496
497/// Parse an R-Matrix Limited (LRF=7) resolved resonance range.
498///
499/// ## ENDF-6 Record Layout (File 2, MT=151, after range CONT + optional TAB1)
500///
501/// ```text
502/// CONT:  [SPI, AP, IFG, KRM, NJS, KRL]
503///        SPI = target spin, AP = global scattering radius (fm),
504///        NJS = number of spin groups (J,π)
505///
506/// LIST:  [0, 0, NPP, 0, 12*NPP, NPP]   ← particle pair definitions
507///        12 values per pair: [MA, MB, ZA, ZB, IA, IB, Q, PNT, SHF, MT, PA, PB]
508///
509/// For each spin group j = 1..NJS:
510///   LIST: [AJ, PJ, KBK, KPS, 6*(NCH+1), NCH+1]   ← header + channels
511///         First 6 values: header row [0, 0, 0, 0, 0, NCH]
512///         NCH × 6 values: [IPP, L, SCH, BND, APE, APT] per channel
513///
514///   LIST: [0, 0, 0, 0, NPL, NRS]                    ← resonance parameters
515///         KRM=2: stride ≥ NCH+1; per resonance: [ER, γ_1, ..., γ_NCH, <padding>]
516///         KRM=3: stride ≥ NCH+2; per resonance: [ER, Γγ, Γ_1, ..., Γ_NCH, <padding>]
517/// ```
518///
519/// Reference: ENDF-6 Formats Manual §2.2.1.6; SAMMY rml/mrml01.f
520fn parse_rmatrix_limited_range(
521    ctx: &mut RangeParseContext<'_>,
522    awr: f64,
523) -> Result<ResonanceRange, EndfParseError> {
524    // CONT: [SPI, AP, IFG, KRM, NJS, KRL]
525    // ENDF AP is in 10⁻¹² cm; convert to fm (×10).
526    let cont = parse_cont(ctx.lines, ctx.pos)?;
527    let target_spin = cont.c1;
528    let scattering_radius = cont.c2 * ENDF_RADIUS_TO_FM;
529    // IFG (L1): radius unit flag.
530    //   IFG=0: AP, APE, APT are in 10⁻¹² cm — universal in ENDF/B-VIII.0.
531    //   IFG=1: radii are in units of ℏ/k (energy-dependent) — not supported here.
532    // SAMMY's WriteRrEndf.cpp always writes IFG=0 and its reader never checks it,
533    // confirming IFG=1 is not used in practice.
534    // Reference: ENDF-6 §2.2.1.6; SAMMY ndf/WriteRrEndf.cpp line 363.
535    let ifg = cont.l1;
536    if ifg != 0 {
537        return Err(EndfParseError::UnsupportedFormat(format!(
538            "LRF=7 IFG={ifg} (energy-dependent radii) is not supported (only IFG=0)"
539        )));
540    }
541    let krm = cont.l2 as u32; // R-matrix type: 2=standard, 3=Reich-Moore approx
542    // P2: Validate KRM at parse time so the physics code never sees an unknown type.
543    // KRM=0/1/4 are defined in the ENDF spec but not supported here.
544    // Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml01.f KRM field.
545    if krm != 2 && krm != 3 {
546        return Err(EndfParseError::UnsupportedFormat(format!(
547            "LRF=7 KRM={krm} is not supported (only KRM=2 and KRM=3)"
548        )));
549    }
550    let njs = checked_count(cont.n1, "NJS")?; // number of spin groups
551    // KRL (N2): kinematics flag.
552    //   KRL=0: non-relativistic kinematics — universal in ENDF/B-VIII.0.
553    //   KRL=1: relativistic kinematics — not supported here.
554    // SAMMY's WriteRrEndf.cpp always writes KRL=0.
555    // Reference: ENDF-6 §2.2.1.6; SAMMY ndf/WriteRrEndf.cpp line 366.
556    let krl = cont.n2;
557    if krl != 0 {
558        return Err(EndfParseError::UnsupportedFormat(format!(
559            "LRF=7 KRL={krl} (relativistic kinematics) is not supported (only KRL=0)"
560        )));
561    }
562
563    // LIST: [0, 0, NPP, 0, 12*NPP, NPP]  — particle pair definitions
564    // NPP is authoritative in L1; N2 is nominally equal but can encode a
565    // different count in some files (e.g. N2 = 2*NPP).  Always derive from L1.
566    // Reference: ENDF-6 Formats Manual §2.2.1.6 Table 2.1.
567    let pp_cont = parse_cont(ctx.lines, ctx.pos)?;
568    let npp = checked_count(pp_cont.l1, "NPP")?;
569    let pp_values = parse_list_values(ctx.lines, ctx.pos, npp * 12)?;
570
571    let mut particle_pairs = Vec::with_capacity(npp);
572    for i in 0..npp {
573        let b = i * 12;
574        particle_pairs.push(ParticlePair {
575            ma: pp_values[b],
576            mb: pp_values[b + 1],
577            za: pp_values[b + 2],
578            zb: pp_values[b + 3],
579            ia: pp_values[b + 4],
580            ib: pp_values[b + 5],
581            q: pp_values[b + 6],
582            pnt: pp_values[b + 7] as i32,
583            shf: pp_values[b + 8] as i32,
584            mt: pp_values[b + 9] as u32,
585            pa: pp_values[b + 10],
586            pb: pp_values[b + 11],
587        });
588    }
589
590    // Coulomb + SHF=1: closed-channel Coulomb shift at imaginary argument is
591    // unimplemented.  Reject at parse time rather than silently producing wrong
592    // dispersive terms near threshold.
593    // Reference: SAMMY rml/mrml07.f — Pghcou is only called for open channels.
594    for (i, pp) in particle_pairs.iter().enumerate() {
595        if pp.za.abs() > 0.5 && pp.zb.abs() > 0.5 && pp.shf == 1 {
596            return Err(EndfParseError::UnsupportedFormat(format!(
597                "LRF=7 particle pair {i}: Coulomb channel (za={}, zb={}) with \
598                 SHF=1 is not supported; closed-channel Coulomb shift at \
599                 imaginary rho is not yet implemented",
600                pp.za, pp.zb
601            )));
602        }
603    }
604
605    // All particle-pair types are now fully supported (with the SHF=1 restriction above):
606    // - PNT 0/1: distinguished by pp.ma in rmatrix_limited.rs.
607    // - SHF 0/1: respected by the shf field in rmatrix_limited.rs.
608    // - Coulomb channels (pp.za > 0 && pp.zb > 0): routed through
609    //   nereids_physics::coulomb (Steed's CF1+CF2, SAMMY coulomb/mrml08.f90).
610    let mut spin_groups = Vec::with_capacity(njs);
611
612    for _ in 0..njs {
613        // LIST: [AJ, PJ, KBK, KPS, 6*(NCH+1), NCH+1]
614        // First 6*(NCH+1) values: header row [0,0,0,0,0,NCH] then NCH×6 channel defs.
615        let sg_cont = parse_cont(ctx.lines, ctx.pos)?;
616        let aj = sg_cont.c1;
617        let pj = sg_cont.c2; // explicit parity field; may be 0.0 when parity is in sign(AJ)
618        let kbk = sg_cont.l1; // background R-matrix flag
619        let kps = sg_cont.l2; // phase shift flag
620
621        // AJ encodes both the spin and, in some evaluations, the parity.
622        // ENDF/B-VIII.0 evaluations such as W-184 use negative AJ for odd-parity
623        // spin groups (e.g., AJ=-0.5, AJ=-1.5) and set PJ=0.
624        // Statistical weight formula (2J+1)/... requires J > 0; negative J yields
625        // zero or negative weights and drives non-physical cross-sections.
626        // Fix: J = |AJ|; parity from sign(AJ) when PJ is absent (PJ=0).
627        // Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml01.f Scan_File_2.
628        let j = aj.abs();
629        let parity = if pj != 0.0 {
630            pj.signum()
631        } else if aj < 0.0 {
632            -1.0
633        } else {
634            1.0
635        };
636        let npl = checked_count(sg_cont.n1, "NPL")?; // 6*(NCH+1)
637        let nch_plus_one = checked_count(sg_cont.n2, "NCH+1")?; // NCH+1
638
639        // NCH+1 <= 1 would imply zero physical channels (NCH = 0), which is
640        // meaningless for a resonance range — every spin group must have at
641        // least one channel.
642        // Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml01.f.
643        if nch_plus_one <= 1 {
644            return Err(EndfParseError::UnsupportedFormat(format!(
645                "RML spin-group LIST: NCH+1 must be >= 2, got NCH+1={nch_plus_one}"
646            )));
647        }
648        let nch = nch_plus_one - 1;
649
650        let sg_values = parse_list_values(ctx.lines, ctx.pos, npl)?;
651
652        // C3: Validate that the LIST record carries at least 6*(NCH+1) values.
653        // NCH is derived from N2 in the LIST header (N2 = NCH+1); the first data row
654        // is a dummy/header row of zeros that ENDF evaluators may fill arbitrarily.
655        // SAMMY (mrml01.f Scan_File_2/ENDF123) reads NCH from N2 and ignores row[5].
656        // Reference: ENDF-6 §2.2.1.6 Table 2.3; SAMMY rml/mrml01.f lines 104-107.
657        let expected_npl = 6 * (nch + 1);
658        if npl < expected_npl {
659            return Err(EndfParseError::UnsupportedFormat(format!(
660                "LRF=7 spin-group LIST: NPL={npl} < 6*(NCH+1)={expected_npl}"
661            )));
662        }
663
664        // First 6 values are the dummy header row (zeros); subsequent NCH×6 values
665        // are channel definitions [IPP, L, SCH, BND, APE, APT] per channel.
666        let npp = particle_pairs.len();
667        let mut channels = Vec::with_capacity(nch);
668        for c in 0..nch {
669            let b = 6 + c * 6; // skip the 6-value header row
670            // C2: IPP is 1-based in ENDF; validate range before converting.
671            let ipp_raw = sg_values[b] as usize;
672            if ipp_raw == 0 || ipp_raw > npp {
673                return Err(EndfParseError::UnsupportedFormat(format!(
674                    "LRF=7 spin-group channel IPP={ipp_raw} is out of range 1..={npp}"
675                )));
676            }
677            // Photon channels (MA < 0.5, PNT=0) are stored as regular channels.
678            // The physics code sets P_c=1, S_c=0, φ_c=0 for massless particles
679            // (rmatrix_limited.rs, ENDF-6 §2.2.1.6 Note 4) and classifies them as
680            // capture channels via pp.mt == 102.  Their reduced width amplitudes
681            // appear at the corresponding column position in the resonance rows,
682            // exactly like any other channel.  Reference: ENDF-6 §2.2.1.6; SAMMY
683            // rml/mrml01.f (Ippx test, mrml07.f P=1 convention for massless).
684            channels.push(RmlChannel {
685                particle_pair_idx: ipp_raw - 1, // convert 1-based ENDF index to 0-based
686                l: sg_values[b + 1] as u32,     // L
687                channel_spin: sg_values[b + 2], // SCH
688                boundary: sg_values[b + 3],     // BND
689                effective_radius: sg_values[b + 4] * ENDF_RADIUS_TO_FM, // APE
690                true_radius: sg_values[b + 5] * ENDF_RADIUS_TO_FM, // APT
691            });
692        }
693
694        // Apply global scattering radius for channels where APE/APT == 0
695        for ch in &mut channels {
696            if ch.effective_radius == 0.0 {
697                ch.effective_radius = scattering_radius;
698            }
699            if ch.true_radius == 0.0 {
700                ch.true_radius = scattering_radius;
701            }
702        }
703
704        // LIST: [0, 0, 0, 0, NPL, NRS]  — resonance parameters
705        // NPL = total values = NRS × (values-per-resonance).
706        // For standard LRF=7, values-per-resonance = NCH+1.
707        // For KRM=3 (e.g. W-184 ENDF/B-VIII.0), evaluators pad each resonance row
708        // to a fixed 6 values per ENDF line, so NPL/NRS = 6 even when NCH=1.
709        // Using hardcoded nch+1 drifts the offset and misreads zeros as energies.
710        // Fix: derive stride directly from NPL/NRS; read only NCH widths per row.
711        // Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml01.f (Scan_File_2 resonance loop).
712        let res_cont = parse_cont(ctx.lines, ctx.pos)?;
713        let nrs = checked_count(res_cont.n2, "NRS")?;
714        let res_npl = checked_count(res_cont.n1, "NPL")?;
715        let res_values = parse_list_values(ctx.lines, ctx.pos, res_npl)?;
716
717        // C4: Validate stride before use — NPL must divide evenly by NRS, and each row
718        // must be at least min_stride values wide.
719        //
720        // KRM=2: per-resonance layout is [ER, Γ_1, ..., Γ_NCH, <padding>]
721        //        → min_stride = NCH+1 (energy + NCH reduced width amplitudes)
722        // KRM=3: per-resonance layout is [ER, Γγ, Γ_1, ..., Γ_NCH, <padding>]
723        //        → min_stride = NCH+2 (energy + Gamgam + NCH partial widths)
724        //
725        // Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml01.f ENDF123 subroutine —
726        //   reads Gamgam at position 1 (immediately after ER), then
727        //   (Gamma,I=1,Ichan) at positions 2..NCH+1.
728        let min_stride = if krm == 3 { nch + 2 } else { nch + 1 };
729        let stride = if nrs == 0 {
730            min_stride // no resonances; stride unused
731        } else {
732            if res_npl % nrs != 0 {
733                return Err(EndfParseError::UnsupportedFormat(format!(
734                    "LRF=7 resonance block NPL={res_npl} is not divisible by NRS={nrs}"
735                )));
736            }
737            let s = res_npl / nrs;
738            if s < min_stride {
739                return Err(EndfParseError::UnsupportedFormat(format!(
740                    "LRF=7 resonance stride={s} < {}={min_stride} \
741                     (KRM={krm}, NPL={res_npl}, NRS={nrs})",
742                    if krm == 3 { "NCH+2" } else { "NCH+1" }
743                )));
744            }
745            s
746        };
747        let mut resonances = Vec::with_capacity(nrs);
748        for r in 0..nrs {
749            let b = r * stride;
750            // Parse resonance row according to KRM column order.
751            //
752            // KRM=2: [ER, γ_1, ..., γ_NCH, <padding>]
753            //   widths (reduced amplitudes γ) start at b+1.
754            //   No capture width column; gamma_gamma = 0.
755            //
756            // KRM=3: [ER, Γγ, Γ_1, ..., Γ_NCH, <padding>]
757            //   Gamgam (radiation width, eV) is at b+1.
758            //   Partial widths Γ_c start at b+2.
759            //   Gamgam forms complex pole energies: Ẽ_n = E_n - i·Γγ/2.
760            //
761            // Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml01.f ENDF123 subroutine.
762            //
763            // Bounds safety: stride ≥ min_stride (verified above), b = r·stride,
764            // and r < nrs, so b + stride ≤ res_npl = res_values.len().
765            // For KRM=3: b+2+nch ≤ b+min_stride ≤ b+stride; guaranteed in bounds.
766            // For KRM=2: b+1+nch ≤ b+min_stride ≤ b+stride; guaranteed in bounds.
767            // Explicit error checks below make the safety locally verifiable and
768            // guard against future changes that might weaken the stride invariant.
769            let (widths, gamma_gamma) = if krm == 3 {
770                let need = b + 2 + nch;
771                if need > res_values.len() {
772                    return Err(EndfParseError::UnsupportedFormat(format!(
773                        "LRF=7 KRM=3 resonance row {r}: need {need} values, \
774                         have {} (stride={stride}, NCH={nch})",
775                        res_values.len()
776                    )));
777                }
778                let gamma_gamma = res_values[b + 1]; // Gamgam at position 1
779                let widths = res_values[b + 2..b + 2 + nch].to_vec(); // Γ_c at positions 2..NCH+1
780                (widths, gamma_gamma)
781            } else {
782                // KRM=2: widths immediately follow ER; no capture-width column.
783                let need = b + 1 + nch;
784                if need > res_values.len() {
785                    return Err(EndfParseError::UnsupportedFormat(format!(
786                        "LRF=7 KRM=2 resonance row {r}: need {need} values, \
787                         have {} (stride={stride}, NCH={nch})",
788                        res_values.len()
789                    )));
790                }
791                (res_values[b + 1..b + 1 + nch].to_vec(), 0.0)
792            };
793            resonances.push(RmlResonance {
794                energy: res_values[b],
795                widths,
796                gamma_gamma,
797            });
798        }
799
800        // KBK: background R-matrix correction (pole-free or smooth background terms).
801        // Per ENDF-6 §2.2.1.6: when KBK > 0 there are NCH background sub-records
802        // per spin group (one per channel), each a CONT+LIST pair; if LBK==1,
803        // two TAB1 records (real and imaginary parts) follow.
804        //
805        // Records are consumed to advance the parser; the background correction
806        // is NOT applied.  This matches SAMMY: all rml/mrml*.f physics files
807        // contain zero references to KBK, LBK, or background R-matrix terms.
808        // SAMMY reads the flags, skips the data, and computes cross-sections
809        // without these corrections.
810        //
811        // Ref: ENDF-6 §2.2.1.6 Table 2.4; OpenScale File2Lrf7.f90 l.269–298.
812        if kbk != 0 {
813            for _ in 0..nch {
814                skip_background_subrecord(ctx.lines, ctx.pos)?;
815            }
816        }
817
818        // KPS: tabulated penetrability/phase-shift override.
819        // Same record structure as KBK.  SAMMY always computes penetrabilities
820        // and phase shifts analytically (mrml07.f Sinsix) and ignores KPS
821        // entirely — zero references to KPS or LPS in any rml/ physics file.
822        // We match that behaviour.
823        //
824        // Ref: ENDF-6 §2.2.1.6 Table 2.5; OpenScale File2Lrf7.f90 l.301–331.
825        if kps != 0 {
826            for _ in 0..nch {
827                skip_background_subrecord(ctx.lines, ctx.pos)?;
828            }
829        }
830
831        spin_groups.push(SpinGroup {
832            j,
833            parity,
834            channels,
835            resonances,
836            has_background_correction: kbk != 0 || kps != 0,
837        });
838    }
839
840    let rml = RmlData {
841        target_spin,
842        awr,
843        scattering_radius,
844        krm,
845        particle_pairs,
846        spin_groups,
847    };
848
849    Ok(ResonanceRange {
850        energy_low: ctx.energy_low,
851        energy_high: ctx.energy_high,
852        resolved: true,
853        formalism: ResonanceFormalism::RMatrixLimited,
854        target_spin,
855        scattering_radius,
856        naps: ctx.naps,
857        ap_table: ctx.ap_table.take(),
858        l_groups: Vec::new(),
859        rml: Some(Box::new(rml)),
860        urr: None,
861        r_external: vec![],
862    })
863}
864
865/// Skip a TAB1 record (CONT + NR interpolation pairs + NP data pairs).
866fn skip_tab1(lines: &[&str], pos: &mut usize) -> Result<(), EndfParseError> {
867    let cont = parse_cont(lines, pos)?;
868    let nr = checked_count(cont.n1, "NR")?; // number of interpolation regions
869    let np = checked_count(cont.n2, "NP")?; // number of data points
870    let nr_lines = (nr * 2).div_ceil(6); // NR×2 integer values (NBT, INT pairs)
871    let np_lines = (np * 2).div_ceil(6); // NP×2 float values (x, y pairs)
872    let needed = nr_lines + np_lines;
873    if *pos + needed > lines.len() {
874        return Err(EndfParseError::UnexpectedEof(format!(
875            "TAB1 skip needs {needed} lines but only {} remain",
876            lines.len() - *pos
877        )));
878    }
879    *pos += needed;
880    Ok(())
881}
882
883/// Maximum sane ENDF count value.
884///
885/// ENDF files in practice never contain more than ~100k resonances per section.
886/// Accepting `i32::MAX` would cause enormous allocations (gigabytes) on
887/// malformed files.  This cap is generous enough for any real evaluation while
888/// protecting against allocation bombs.
889const MAX_ENDF_COUNT: i32 = 1_000_000;
890
891/// Validate that an ENDF integer count is non-negative and return as `usize`.
892///
893/// Malformed records can contain negative counts which, if cast directly to
894/// `usize`, wrap to huge values and cause OOM panics in `Vec::with_capacity`
895/// or `parse_list_values`.
896fn checked_count(value: i32, label: &str) -> Result<usize, EndfParseError> {
897    if value < 0 {
898        return Err(EndfParseError::UnsupportedFormat(format!(
899            "Negative ENDF count: {label}={value}"
900        )));
901    }
902    if value > MAX_ENDF_COUNT {
903        return Err(EndfParseError::UnsupportedFormat(format!(
904            "ENDF count too large: {label}={value} (maximum {MAX_ENDF_COUNT})"
905        )));
906    }
907    Ok(value as usize)
908}
909
910/// Skip an unsupported URR body **with LFW=0** layout.
911///
912/// Called when LRU=2 has an LRF value other than 1 or 2 so that the records
913/// for this range are consumed and subsequent ranges can still be parsed.
914///
915/// Structure consumed (ENDF-6 §2.2.2, LFW=0):
916/// ```text
917/// CONT: SPI, AP, 0, 0, NLS, 0
918/// For each L (NLS times):
919///   CONT: AWRI, 0, L, 0, N1, N2
920///   if N2 > 0  -> LRF=1 style: one LIST record of N1 values
921///   if N2 == 0 -> LRF=2 style: N1 J-sub-blocks, each = CONT + LIST(N1_j values)
922/// ```
923fn skip_urr_body(lines: &[&str], pos: &mut usize) -> Result<(), EndfParseError> {
924    // CONT: SPI, AP, 0, 0, NLS, 0
925    let header = parse_cont(lines, pos)?;
926    let nls = checked_count(header.n1, "NLS")?;
927
928    for _ in 0..nls {
929        // L CONT: AWRI, 0, L, 0, N1, N2
930        let l_cont = parse_cont(lines, pos)?;
931        let n1 = checked_count(l_cont.n1, "N1")?;
932        let n2 = checked_count(l_cont.n2, "N2")?;
933
934        if n2 > 0 {
935            // LRF=1 style: N2=NJS, N1=6*NJS — single LIST record.
936            parse_list_values(lines, pos, n1)?;
937        } else {
938            // LRF=2 style: N1=NJS, N2=0 — N1 J-sub-blocks, each with their
939            // own CONT (carrying 6*(NE+1) in N1) followed by a LIST record.
940            for _ in 0..n1 {
941                let j_cont = parse_cont(lines, pos)?;
942                let jn1 = checked_count(j_cont.n1, "N1")?;
943                parse_list_values(lines, pos, jn1)?;
944            }
945        }
946    }
947    Ok(())
948}
949
950/// Skip one background sub-record: a CONT+LIST pair plus (if LBK/LPS == 1)
951/// two TAB1 records for the real and imaginary tabulated parts.
952///
953/// Used to consume KBK and KPS background blocks in LRF=7 spin groups.
954///
955/// Per ENDF-6 §2.2.1.6 and OpenScale File2Lrf7.f90:
956/// - CONT: [ED, EU, LBK_or_LPS, <unused>, N1, N2]
957///   where L1 (LBK_or_LPS) is the type flag: LBK for KBK blocks, LPS for KPS blocks.
958/// - LIST: N1 data values
959/// - If LBK_or_LPS == 1: real TAB1 + imaginary TAB1
960fn skip_background_subrecord(lines: &[&str], pos: &mut usize) -> Result<(), EndfParseError> {
961    let cont = parse_cont(lines, pos)?;
962    let lbk_or_lps = cont.l1;
963    let n1 = checked_count(cont.n1, "N1")?;
964    let list_lines = n1.div_ceil(6);
965    if *pos + list_lines > lines.len() {
966        return Err(EndfParseError::UnexpectedEof(format!(
967            "Background sub-record LIST needs {list_lines} lines but only {} remain",
968            lines.len() - *pos
969        )));
970    }
971    *pos += list_lines;
972    if lbk_or_lps == 1 {
973        skip_tab1(lines, pos)?;
974        skip_tab1(lines, pos)?;
975    }
976    Ok(())
977}
978
979// ---------------------------------------------------------------------------
980// Low-level ENDF line parsing helpers
981// ---------------------------------------------------------------------------
982
983/// Parsed CONT (control) record with 2 floats, 4 integers.
984struct ContRecord {
985    c1: f64,
986    c2: f64,
987    l1: i32,
988    l2: i32,
989    n1: i32,
990    n2: i32,
991}
992
993/// Parse a CONT record from the current line.
994fn parse_cont(lines: &[&str], pos: &mut usize) -> Result<ContRecord, EndfParseError> {
995    if *pos >= lines.len() {
996        return Err(EndfParseError::UnexpectedEof(
997            "Expected CONT record but reached end of data".to_string(),
998        ));
999    }
1000    let line = lines[*pos];
1001    *pos += 1;
1002
1003    Ok(ContRecord {
1004        c1: parse_endf_float(line, 0)?,
1005        c2: parse_endf_float(line, 1)?,
1006        l1: parse_endf_int(line, 2)?,
1007        l2: parse_endf_int(line, 3)?,
1008        n1: parse_endf_int(line, 4)?,
1009        n2: parse_endf_int(line, 5)?,
1010    })
1011}
1012
1013/// Parse a LIST of floating-point values spanning multiple lines.
1014///
1015/// ENDF packs 6 values per line. We read ceil(n/6) lines.
1016fn parse_list_values(
1017    lines: &[&str],
1018    pos: &mut usize,
1019    n_values: usize,
1020) -> Result<Vec<f64>, EndfParseError> {
1021    let mut values = Vec::with_capacity(n_values);
1022    let n_lines = n_values.div_ceil(6);
1023
1024    for _ in 0..n_lines {
1025        if *pos >= lines.len() {
1026            return Err(EndfParseError::UnexpectedEof(
1027                "Expected LIST data but reached end".to_string(),
1028            ));
1029        }
1030        let line = lines[*pos];
1031        *pos += 1;
1032
1033        let remaining = n_values - values.len();
1034        let fields_on_line = remaining.min(6);
1035
1036        for field in 0..fields_on_line {
1037            values.push(parse_endf_float(line, field)?);
1038        }
1039    }
1040
1041    Ok(values)
1042}
1043
1044/// Parse a floating-point value from an 11-character ENDF field.
1045///
1046/// ENDF uses Fortran-style floats that may omit 'E', e.g.:
1047/// - " 1.234567+2" means 1.234567e+2
1048/// - "-3.456789-1" means -3.456789e-1
1049/// - " 0.000000+0" means 0.0
1050fn parse_endf_float(line: &str, field_index: usize) -> Result<f64, EndfParseError> {
1051    let start = field_index * 11;
1052    let end = start + 11;
1053
1054    if line.len() < end {
1055        // Short line — treat as zero.
1056        return Ok(0.0);
1057    }
1058
1059    let field = &line[start..end];
1060    let trimmed = field.trim();
1061
1062    if trimmed.is_empty() {
1063        return Ok(0.0);
1064    }
1065
1066    // Try standard Rust float parsing first.
1067    if let Ok(v) = trimmed.parse::<f64>() {
1068        return Ok(v);
1069    }
1070
1071    // Handle Fortran-style: "1.234567+2" or "-3.456789-1"
1072    // Look for +/- that is NOT the first character and NOT preceded by 'e'/'E'/'d'/'D'.
1073    let bytes = trimmed.as_bytes();
1074    for i in 1..bytes.len() {
1075        if (bytes[i] == b'+' || bytes[i] == b'-')
1076            && bytes[i - 1] != b'e'
1077            && bytes[i - 1] != b'E'
1078            && bytes[i - 1] != b'd'
1079            && bytes[i - 1] != b'D'
1080            && bytes[i - 1] != b'+'
1081            && bytes[i - 1] != b'-'
1082        {
1083            let mantissa = &trimmed[..i];
1084            let exp_slice = &trimmed[i..];
1085            // Strip spaces from the exponent only when present (some ENDF files
1086            // write "+ 4" not "+4").  Avoid allocation on the common path.
1087            let with_e = if exp_slice.contains(' ') {
1088                let exponent: String = exp_slice.chars().filter(|c| !c.is_whitespace()).collect();
1089                format!("{}E{}", mantissa, exponent)
1090            } else {
1091                format!("{}E{}", mantissa, exp_slice)
1092            };
1093            if let Ok(v) = with_e.parse::<f64>() {
1094                return Ok(v);
1095            }
1096        }
1097    }
1098
1099    Err(EndfParseError::InvalidNumber(format!(
1100        "Cannot parse ENDF float: '{}'",
1101        field
1102    )))
1103}
1104
1105/// Parse an integer from an 11-character ENDF field.
1106fn parse_endf_int(line: &str, field_index: usize) -> Result<i32, EndfParseError> {
1107    let start = field_index * 11;
1108    let end = start + 11;
1109
1110    if line.len() < end {
1111        return Ok(0);
1112    }
1113
1114    let field = &line[start..end];
1115    let trimmed = field.trim();
1116
1117    if trimmed.is_empty() {
1118        return Ok(0);
1119    }
1120
1121    // ENDF integers may have a decimal point (e.g., "1.000000+0" for 1).
1122    // Try integer parse first, then float-to-int.
1123    if let Ok(v) = trimmed.parse::<i32>() {
1124        return Ok(v);
1125    }
1126
1127    // Try parsing as float then truncating.
1128    if let Ok(v) = parse_endf_float(line, field_index) {
1129        return Ok(v as i32);
1130    }
1131
1132    Err(EndfParseError::InvalidNumber(format!(
1133        "Cannot parse ENDF int: '{}'",
1134        field
1135    )))
1136}
1137
1138/// Parse a URR range with LFW=1, LRF=1 (energy-dependent fission widths,
1139/// single-level BW).
1140///
1141/// ## Record layout (ENDF-6 §2.2.2.1 "Case B")
1142/// ```text
1143/// CONT: SPI, AP, LSSF, 0, NE, NLS
1144/// LIST: NE energy values (shared fission width grid)
1145/// For each L:
1146///   CONT: AWRI, 0, L, 0, NJS, 0
1147///   For each J:
1148///     LIST: [D, AJ, AMUN, GNO, GG, 0] + NE fission widths
1149/// ```
1150///
1151/// Other widths (D, GNO, GG) are energy-independent (single values).
1152/// Only fission widths (GF) are tabulated at the shared energy grid.
1153///
1154/// Reference: ENDF-6 §2.2.2.1 Case B; SAMMY File2Unres.f90 l.165
1155fn parse_urr_lfw1_lrf1(ctx: &mut RangeParseContext<'_>) -> Result<ResonanceRange, EndfParseError> {
1156    // CONT: SPI, AP, LSSF, 0, NE, NLS
1157    let header = parse_cont(ctx.lines, ctx.pos)?;
1158    let spi = header.c1;
1159    let ap = header.c2 * ENDF_RADIUS_TO_FM;
1160    let ne = checked_count(header.n1, "NE")?;
1161    let nls = checked_count(header.n2, "NLS")?;
1162
1163    // LIST: NE energy values (shared fission width grid)
1164    let fission_energies = parse_list_values(ctx.lines, ctx.pos, ne)?;
1165
1166    let mut l_groups = Vec::with_capacity(nls);
1167
1168    for _ in 0..nls {
1169        // CONT: AWRI, 0, L, 0, NJS, 0
1170        let l_cont = parse_cont(ctx.lines, ctx.pos)?;
1171        let awri = l_cont.c1;
1172        let l = l_cont.l1 as u32;
1173        let njs = checked_count(l_cont.n1, "NJS")?;
1174
1175        let mut j_groups = Vec::with_capacity(njs);
1176
1177        for _ in 0..njs {
1178            // LIST: [D, AJ, AMUN, GNO, GG, 0] + NE fission widths
1179            let values = parse_list_values(ctx.lines, ctx.pos, ne + 6)?;
1180
1181            let d = values[0];
1182            let aj = values[1];
1183            let amun = values[2];
1184            let gno = values[3];
1185            let gg = values[4];
1186            // values[5] = 0 (unused)
1187
1188            // Fission widths from the shared energy grid
1189            let gf: Vec<f64> = values[6..6 + ne].to_vec();
1190
1191            j_groups.push(UrrJGroup {
1192                j: aj,
1193                amun,
1194                amuf: 0.0, // LRF=1 doesn't have AMUF
1195                energies: fission_energies.clone(),
1196                d: vec![d],
1197                gx: vec![0.0],
1198                gn: vec![gno],
1199                gg: vec![gg],
1200                gf,
1201                int_code: 2, // Default lin-lin for fission width interpolation
1202            });
1203        }
1204
1205        l_groups.push(UrrLGroup { l, awri, j_groups });
1206    }
1207
1208    Ok(ResonanceRange {
1209        energy_low: ctx.energy_low,
1210        energy_high: ctx.energy_high,
1211        resolved: false,
1212        formalism: ResonanceFormalism::Unresolved,
1213        target_spin: spi,
1214        scattering_radius: ap,
1215        naps: ctx.naps,
1216        ap_table: ctx.ap_table.take(),
1217        l_groups: Vec::new(),
1218        rml: None,
1219        urr: Some(Box::new(UrrData {
1220            lrf: 1,
1221            spi,
1222            ap,
1223            e_low: ctx.energy_low,
1224            e_high: ctx.energy_high,
1225            l_groups,
1226        })),
1227        r_external: vec![],
1228    })
1229}
1230
1231/// Parse an Unresolved Resonance Region (LRU=2) range for LFW=0.
1232///
1233/// Supports LRF=1 (energy-independent widths) and LRF=2 (tabulated widths).
1234/// For LFW=1/LRF=1, see `parse_urr_lfw1_lrf1`.
1235/// For LFW=1/LRF=2, the record layout is identical to LFW=0/LRF=2.
1236///
1237/// Reference: ENDF-6 Formats Manual §2.2.2; SAMMY `unr/munr03.f90`
1238fn parse_urr_range(
1239    ctx: &mut RangeParseContext<'_>,
1240    lrf: i32,
1241) -> Result<ResonanceRange, EndfParseError> {
1242    use crate::resonance::{UrrData, UrrJGroup, UrrLGroup};
1243
1244    // Caller guarantees LFW=0 before entering this function.
1245    // LFW=1 (energy-dependent fission widths) is handled by
1246    // skip_urr_lfw1_body in the caller.
1247
1248    // CONT: SPI, AP, 0, 0, NLS, 0
1249    // ENDF AP is in 10⁻¹² cm; convert to fm (×10).
1250    let spi_cont = parse_cont(ctx.lines, ctx.pos)?;
1251    let spi = spi_cont.c1;
1252    let ap = spi_cont.c2 * ENDF_RADIUS_TO_FM; // scattering radius (fm)
1253    let nls = checked_count(spi_cont.n1, "NLS")?;
1254
1255    let mut l_groups = Vec::with_capacity(nls);
1256
1257    if lrf == 1 {
1258        // LRF=1: energy-independent widths, one LIST block per L covering all J.
1259        for _ in 0..nls {
1260            // CONT: AWRI, 0, L, 0, N1=6*NJS, N2=NJS
1261            let l_cont = parse_cont(ctx.lines, ctx.pos)?;
1262            let awri = l_cont.c1;
1263            if l_cont.l1 < 0 {
1264                return Err(EndfParseError::UnsupportedFormat(format!(
1265                    "URR LRF=1: negative L={}",
1266                    l_cont.l1
1267                )));
1268            }
1269            let l = l_cont.l1 as u32;
1270            let n1 = checked_count(l_cont.n1, "N1")?; // 6*NJS
1271            let njs = checked_count(l_cont.n2, "NJS")?;
1272
1273            if njs == 0 || n1 != 6 * njs {
1274                return Err(EndfParseError::UnsupportedFormat(format!(
1275                    "URR LRF=1 L={l}: N1={n1} ≠ 6×NJS={} (NJS={njs})",
1276                    6 * njs
1277                )));
1278            }
1279
1280            let values = parse_list_values(ctx.lines, ctx.pos, n1)?;
1281
1282            let mut j_groups = Vec::with_capacity(njs);
1283            for j_idx in 0..njs {
1284                let base = j_idx * 6;
1285                // [D, AJ, AMUN, GNO, GG, GF]
1286                j_groups.push(UrrJGroup {
1287                    j: values[base + 1],        // AJ
1288                    amun: values[base + 2],     // AMUN (neutron DOF)
1289                    amuf: 0.0,                  // LRF=1 format does not carry AMUF
1290                    energies: vec![],           // Energy-independent
1291                    d: vec![values[base]],      // D (level spacing, eV)
1292                    gx: vec![0.0],              // No competitive width in LRF=1
1293                    gn: vec![values[base + 3]], // GNO (reduced neutron width, eV)
1294                    gg: vec![values[base + 4]], // GG (gamma width, eV)
1295                    gf: vec![values[base + 5]], // GF (fission width, eV)
1296                    int_code: 2,                // LRF=1 has no table; default lin-lin
1297                });
1298            }
1299
1300            l_groups.push(UrrLGroup { l, awri, j_groups });
1301        }
1302    } else {
1303        // LRF=2: energy-dependent width tables, one LIST per (L, J).
1304        for _l_idx in 0..nls {
1305            // CONT: AWRI, 0, L, 0, NJS, 0
1306            let l_cont = parse_cont(ctx.lines, ctx.pos)?;
1307            let awri = l_cont.c1;
1308            if l_cont.l1 < 0 {
1309                return Err(EndfParseError::UnsupportedFormat(format!(
1310                    "URR LRF=2: negative L={}",
1311                    l_cont.l1
1312                )));
1313            }
1314            let l = l_cont.l1 as u32;
1315            let njs = checked_count(l_cont.n1, "NJS")?; // N1 = NJS for LRF=2
1316
1317            // Zero NJS means no J-groups for this L-value, which is malformed
1318            // (ENDF §2.2.2.2 requires at least one J-group per L-group).
1319            // Consistent with the LRF=1 path which also rejects NJS=0.
1320            if njs == 0 {
1321                return Err(EndfParseError::UnsupportedFormat(format!(
1322                    "URR LRF=2 L={l}: NJS=0 (at least one J-group required)"
1323                )));
1324            }
1325
1326            let mut j_groups = Vec::with_capacity(njs);
1327            for _j_idx in 0..njs {
1328                // CONT: AJ, 0, INT, 0, N1=6*(NE+1), N2=NE
1329                let j_cont = parse_cont(ctx.lines, ctx.pos)?;
1330                let aj = j_cont.c1;
1331                let int_code = j_cont.l1; // interpolation law (L1 field)
1332                // Negative INT is a malformed ENDF record, not merely an
1333                // unimplemented mode — reject it outright.
1334                if int_code < 0 {
1335                    return Err(EndfParseError::UnsupportedFormat(format!(
1336                        "URR LRF=2 J={aj}: negative INT={int_code}"
1337                    )));
1338                }
1339                let n1 = checked_count(j_cont.n1, "N1")?; // 6*(NE+1)
1340                let ne = checked_count(j_cont.n2, "NE")?; // NE (number of energy points)
1341
1342                // Validate N1 = 6*(NE+1) before consuming any LIST body.
1343                // This catches malformed records regardless of whether the INT
1344                // code is supported, preventing over-/under-consumption of lines.
1345                // SAMMY only validates this for LFW=1/LRF=1 (File2.cpp line 1031);
1346                // we validate unconditionally since we actually parse URR data.
1347                let expected_n1 = 6 * (ne + 1);
1348                if n1 != expected_n1 {
1349                    return Err(EndfParseError::UnsupportedFormat(format!(
1350                        "URR LRF=2 J={aj}: N1={n1} ≠ 6*(NE+1)={expected_n1} (NE={ne})"
1351                    )));
1352                }
1353
1354                // All ENDF interpolation laws (INT=1..5) are now supported
1355                // in the URR physics module (urr.rs).
1356                // INT=1: histogram, INT=2: lin-lin, INT=3: log-lin,
1357                // INT=4: lin-log, INT=5: log-log.
1358                // ENDF-6 §2.2.2.2; SAMMY unr/munr01.f90.
1359
1360                let values = parse_list_values(ctx.lines, ctx.pos, n1)?;
1361
1362                // Row 0 (DOF): [0, 0, 0, AMUN, 0, AMUF]
1363                let amun = values[3];
1364                let amuf = values[5];
1365
1366                // Rows 1..NE: [E_i, D_i, GX_i, GN_i, GG_i, GF_i]
1367                let mut energies = Vec::with_capacity(ne);
1368                let mut d = Vec::with_capacity(ne);
1369                let mut gx = Vec::with_capacity(ne);
1370                let mut gn = Vec::with_capacity(ne);
1371                let mut gg = Vec::with_capacity(ne);
1372                let mut gf = Vec::with_capacity(ne);
1373
1374                for row in 0..ne {
1375                    let base = (row + 1) * 6; // +1 to skip the DOF row
1376                    energies.push(values[base]);
1377                    d.push(values[base + 1]);
1378                    gx.push(values[base + 2]);
1379                    gn.push(values[base + 3]);
1380                    gg.push(values[base + 4]);
1381                    gf.push(values[base + 5]);
1382                }
1383
1384                // Deduplicate energy grid: some evaluations (e.g., JENDL-5
1385                // Eu-151, Eu-153) contain duplicate energy points. SAMMY
1386                // silently accepts these. We keep the LAST occurrence of
1387                // each duplicate energy, matching SAMMY behavior.
1388                // Exact f64 equality is correct: ENDF duplicates are
1389                // bitwise-identical copies in the same file record.
1390                // Issue: #402
1391                {
1392                    let n = energies.len();
1393                    if n > 1 {
1394                        // O(n) backwards compaction: keep last of each run.
1395                        let mut write = n - 1;
1396                        let mut last_e = energies[n - 1];
1397                        let mut read = n - 1;
1398                        while read > 0 {
1399                            read -= 1;
1400                            if energies[read] == last_e {
1401                                continue;
1402                            }
1403                            write -= 1;
1404                            energies[write] = energies[read];
1405                            d[write] = d[read];
1406                            gx[write] = gx[read];
1407                            gn[write] = gn[read];
1408                            gg[write] = gg[read];
1409                            gf[write] = gf[read];
1410                            last_e = energies[read];
1411                        }
1412                        let new_len = n - write;
1413                        energies.copy_within(write..n, 0);
1414                        d.copy_within(write..n, 0);
1415                        gx.copy_within(write..n, 0);
1416                        gn.copy_within(write..n, 0);
1417                        gg.copy_within(write..n, 0);
1418                        gf.copy_within(write..n, 0);
1419                        energies.truncate(new_len);
1420                        d.truncate(new_len);
1421                        gx.truncate(new_len);
1422                        gn.truncate(new_len);
1423                        gg.truncate(new_len);
1424                        gf.truncate(new_len);
1425                    }
1426                }
1427
1428                // Validate that the (now deduplicated) URR energy grid is
1429                // strictly ascending (precondition of table_interp).
1430                for i in 0..energies.len().saturating_sub(1) {
1431                    if energies[i] >= energies[i + 1] {
1432                        return Err(EndfParseError::UnsupportedFormat(format!(
1433                            "URR energy grid must be strictly ascending \
1434                             (AJ={aj}, index {i}: {} >= {})",
1435                            energies[i],
1436                            energies[i + 1]
1437                        )));
1438                    }
1439                }
1440
1441                j_groups.push(UrrJGroup {
1442                    j: aj,
1443                    amun,
1444                    amuf,
1445                    energies,
1446                    d,
1447                    gx,
1448                    gn,
1449                    gg,
1450                    gf,
1451                    int_code: {
1452                        // By this point we have verified int_code is 2 or 5
1453                        // (the early-return above handles all other values).
1454                        debug_assert!(
1455                            int_code == 2 || int_code == 5,
1456                            "int_code must be 2 or 5, got {int_code}"
1457                        );
1458                        int_code as u32
1459                    },
1460                });
1461            }
1462
1463            l_groups.push(UrrLGroup { l, awri, j_groups });
1464        }
1465    }
1466
1467    // ENDF-6 §2.2.2: LRF for URR is 1 or 2. Guard before i32→u32 cast.
1468    debug_assert!(lrf == 1 || lrf == 2, "URR LRF must be 1 or 2, got: {lrf}");
1469
1470    let urr = UrrData {
1471        lrf: lrf as u32,
1472        spi,
1473        ap,
1474        e_low: ctx.energy_low,
1475        e_high: ctx.energy_high,
1476        l_groups,
1477    };
1478
1479    Ok(ResonanceRange {
1480        energy_low: ctx.energy_low,
1481        energy_high: ctx.energy_high,
1482        resolved: false,
1483        formalism: ResonanceFormalism::Unresolved,
1484        target_spin: spi,
1485        scattering_radius: ap,
1486        naps: ctx.naps,
1487        ap_table: ctx.ap_table.take(),
1488        l_groups: Vec::new(),
1489        rml: None,
1490        urr: Some(Box::new(urr)),
1491        r_external: vec![],
1492    })
1493}
1494
1495/// Parse a TAB1 record into a `Tab1` interpolation table.
1496///
1497/// ENDF TAB1 layout (Reference: ENDF-6 Formats Manual §0.5):
1498/// ```text
1499/// CONT: [C1, C2, L1, L2, NR, NP]
1500/// NR×2 integer values: (NBT_i, INT_i) pairs  — 6 per line
1501/// NP×2 float values:   (x_i,   y_i)   pairs  — 6 per line
1502/// ```
1503///
1504/// INT codes: 1=histogram, 2=lin-lin, 3=log-x/lin-y, 4=lin-x/log-y, 5=log-log.
1505fn parse_tab1(lines: &[&str], pos: &mut usize) -> Result<Tab1, EndfParseError> {
1506    let cont = parse_cont(lines, pos)?;
1507    let nr = checked_count(cont.n1, "NR")?; // number of interpolation regions
1508    let np = checked_count(cont.n2, "NP")?; // number of data points
1509
1510    // NR=0 is valid ENDF: it means a single implicit interpolation region
1511    // covering all NP points with no explicit boundary record.  The
1512    // evaluate() call will fall through to the `unwrap_or(2)` default in
1513    // interp_code_for_interval(), which correctly returns INT=2 (lin-lin).
1514    // When NR=0, the loop below is a no-op and the interp_raw vec stays empty.
1515
1516    // Read NR×2 integers: (NBT, INT) pairs packed as ENDF floats.
1517    // Validate that values are integers, INT codes are in 1..=5, boundaries
1518    // are strictly increasing, and the last boundary equals NP.
1519    let interp_raw = parse_list_values(lines, pos, nr * 2)?;
1520    let mut boundaries = Vec::with_capacity(nr);
1521    let mut interp_codes = Vec::with_capacity(nr);
1522    for i in 0..nr {
1523        let nbt_raw = interp_raw[i * 2];
1524        let int_raw = interp_raw[i * 2 + 1];
1525
1526        // ENDF stores integers as floats (e.g. "2.000000+0").  They must be
1527        // exact whole numbers.  Use a small epsilon (1e-6) rather than the
1528        // half-unit tolerance 0.5, which would silently accept 1.4 or 2.49.
1529        // NBT is a 1-based index (ENDF §0.5), so 0 is invalid.
1530        if (nbt_raw - nbt_raw.round()).abs() > 1e-6 || nbt_raw < 1.0 {
1531            return Err(EndfParseError::UnsupportedFormat(format!(
1532                "TAB1 NBT[{}] is not a positive integer: {}",
1533                i, nbt_raw
1534            )));
1535        }
1536        if (int_raw - int_raw.round()).abs() > 1e-6 {
1537            return Err(EndfParseError::UnsupportedFormat(format!(
1538                "TAB1 INT[{}] is not an integer: {}",
1539                i, int_raw
1540            )));
1541        }
1542        let int_code = int_raw.round() as u32;
1543        if !(1..=5).contains(&int_code) {
1544            return Err(EndfParseError::UnsupportedFormat(format!(
1545                "TAB1 INT[{}]={} is out of range 1..=5",
1546                i, int_code
1547            )));
1548        }
1549        let nbt = nbt_raw.round() as usize;
1550
1551        // Boundaries must be strictly increasing (ENDF §0.5).
1552        if let Some(&prev) = boundaries.last()
1553            && nbt <= prev
1554        {
1555            return Err(EndfParseError::UnsupportedFormat(format!(
1556                "TAB1 NBT[{}]={} is not greater than NBT[{}]={}",
1557                i,
1558                nbt,
1559                i - 1,
1560                prev
1561            )));
1562        }
1563        boundaries.push(nbt);
1564        interp_codes.push(int_code);
1565    }
1566
1567    // The final boundary must equal NP (ENDF §0.5: last NBT is 1-based index of last point).
1568    if nr > 0 {
1569        let last_nbt = *boundaries.last().unwrap();
1570        if last_nbt != np {
1571            return Err(EndfParseError::UnsupportedFormat(format!(
1572                "TAB1 last NBT={} does not equal NP={}",
1573                last_nbt, np
1574            )));
1575        }
1576    }
1577
1578    if np == 0 {
1579        return Err(EndfParseError::UnsupportedFormat(
1580            "TAB1 NP=0: table must have at least one point".to_string(),
1581        ));
1582    }
1583
1584    // Read NP×2 floats: (E, AP) pairs.
1585    let data_raw = parse_list_values(lines, pos, np * 2)?;
1586    let mut points = Vec::with_capacity(np);
1587    for i in 0..np {
1588        let x = data_raw[i * 2];
1589        let y = data_raw[i * 2 + 1];
1590        // x-values must be strictly increasing; Tab1::evaluate() relies on this.
1591        if let Some(&(x_prev, _)) = points.last()
1592            && x <= x_prev
1593        {
1594            return Err(EndfParseError::UnsupportedFormat(format!(
1595                "TAB1 x[{}]={} is not greater than x[{}]={} (x must be strictly increasing)",
1596                i,
1597                x,
1598                i - 1,
1599                x_prev
1600            )));
1601        }
1602        points.push((x, y));
1603    }
1604
1605    Ok(Tab1 {
1606        boundaries,
1607        interp_codes,
1608        points,
1609    })
1610}
1611
1612/// Errors from ENDF parsing.
1613#[derive(Debug, thiserror::Error)]
1614pub enum EndfParseError {
1615    #[error("Missing section: {0}")]
1616    MissingSection(String),
1617
1618    #[error("Unsupported format: {0}")]
1619    UnsupportedFormat(String),
1620
1621    #[error("Invalid number: {0}")]
1622    InvalidNumber(String),
1623
1624    #[error("Unexpected end of file: {0}")]
1625    UnexpectedEof(String),
1626
1627    #[error("Invalid isotope: {0}")]
1628    InvalidIsotope(#[from] nereids_core::error::NereidsError),
1629}
1630
1631#[cfg(test)]
1632mod tests {
1633    use super::*;
1634
1635    // NOTE: Every ENDF test fixture line must be at least 75 characters long.
1636    // The MF/MT filter in `parse_endf_file2` checks `line.len() < 75` and
1637    // discards shorter lines.  ENDF lines are exactly 80 characters in the
1638    // real format.  If a test fixture line is truncated below 75 chars, it
1639    // will be silently dropped and the test will fail with "No MF=2, MT=151
1640    // data found" rather than a useful error.
1641
1642    #[test]
1643    fn test_parse_endf_float_standard() {
1644        // ENDF fields are exactly 11 chars wide, no separators.
1645        // " 1.23456+2" in 11 chars = " 1.23456+02" (Fortran E11.4 style)
1646        //  01234567890  (field 0: cols 0-10, field 1: cols 11-21, etc.)
1647        let line = " 1.23456+02 2.34567-01 0.00000+00                                            ";
1648        assert!((parse_endf_float(line, 0).unwrap() - 123.456).abs() < 0.01);
1649        assert!((parse_endf_float(line, 1).unwrap() - 0.234567).abs() < 1e-6);
1650        assert!((parse_endf_float(line, 2).unwrap() - 0.0).abs() < 1e-10);
1651    }
1652
1653    #[test]
1654    fn test_parse_endf_float_with_e() {
1655        // 11-char fields: "1.23456E+02" "2.34567E-01"
1656        let line = "1.23456E+022.34567E-01                                                       ";
1657        assert!((parse_endf_float(line, 0).unwrap() - 123.456).abs() < 0.01);
1658        assert!((parse_endf_float(line, 1).unwrap() - 0.234567).abs() < 1e-6);
1659    }
1660
1661    #[test]
1662    fn test_parse_endf_float_negative() {
1663        let line = "-1.23456+02-2.34567-01                                                       ";
1664        assert!((parse_endf_float(line, 0).unwrap() - (-123.456)).abs() < 0.01);
1665        assert!((parse_endf_float(line, 1).unwrap() - (-0.234567)).abs() < 1e-6);
1666    }
1667
1668    /// Fortran exponents with a space between the sign and digit — e.g. "9.22330+ 4"
1669    /// — appear in some older ENDF evaluations (observed in SAMMY tr149/t149a.endf
1670    /// for U-233).  The parser strips the space before parsing the exponent.
1671    #[test]
1672    fn test_parse_endf_float_spaced_exponent() {
1673        // " 9.22330+ 4" occupies 11 chars: space before mantissa, space before digit
1674        let line =
1675            " 9.22330+ 4 1.23400- 2                                                         ";
1676        assert!((parse_endf_float(line, 0).unwrap() - 92_233.0).abs() < 1.0);
1677        assert!((parse_endf_float(line, 1).unwrap() - 0.01234).abs() < 1e-6);
1678    }
1679
1680    #[test]
1681    fn test_parse_endf_int() {
1682        let line = "          0          1          2          3          4          5            ";
1683        assert_eq!(parse_endf_int(line, 0).unwrap(), 0);
1684        assert_eq!(parse_endf_int(line, 1).unwrap(), 1);
1685        assert_eq!(parse_endf_int(line, 2).unwrap(), 2);
1686    }
1687
1688    /// Parse the SAMMY ex027 ENDF file for U-238 (Reich-Moore, LRF=3).
1689    ///
1690    /// This test validates against the SAMMY-distributed ENDF file.
1691    /// The first positive-energy resonance of U-238 is at 6.674 eV.
1692    #[test]
1693    fn test_parse_u238_sammy_endf() {
1694        let endf_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1695            .parent()
1696            .unwrap()
1697            .parent()
1698            .unwrap()
1699            .join("../SAMMY/SAMMY/samexm_new/ex027_new/ex027.endf");
1700
1701        if !endf_path.exists() {
1702            println!("Skipping: SAMMY checkout not found at {:?}", endf_path);
1703            return;
1704        }
1705
1706        let endf_text = std::fs::read_to_string(&endf_path).unwrap();
1707        let data = parse_endf_file2(&endf_text).unwrap();
1708
1709        // Basic structure checks.
1710        assert_eq!(data.za, 92238, "Should be U-238");
1711        assert!((data.awr - 236.006).abs() < 0.01, "AWR should be ~236");
1712        assert!(!data.ranges.is_empty(), "Should have at least one range");
1713
1714        let range = &data.ranges[0];
1715        assert!(range.resolved, "First range should be resolved");
1716        assert_eq!(
1717            range.formalism,
1718            ResonanceFormalism::ReichMoore,
1719            "U-238 ENDF uses Reich-Moore (LRF=3)"
1720        );
1721        assert!(
1722            (range.target_spin - 0.0).abs() < 1e-10,
1723            "U-238 target spin I=0"
1724        );
1725        assert!(
1726            (range.scattering_radius - 9.4285).abs() < 0.01,
1727            "Scattering radius ~9.4285 fm (ENDF 0.94285 × 10)"
1728        );
1729        assert_eq!(range.l_groups.len(), 2, "Should have L=0 and L=1 groups");
1730
1731        // Check first L-group (L=0).
1732        let l0 = &range.l_groups[0];
1733        assert_eq!(l0.l, 0, "First group should be L=0");
1734        assert!(
1735            l0.resonances.len() > 500,
1736            "L=0 should have hundreds of resonances"
1737        );
1738
1739        // Find the famous 6.674 eV resonance of U-238.
1740        let first_positive = l0
1741            .resonances
1742            .iter()
1743            .find(|r| r.energy > 0.0)
1744            .expect("Should have positive-energy resonances");
1745        assert!(
1746            (first_positive.energy - 6.674).abs() < 0.01,
1747            "First positive resonance should be at 6.674 eV, got {}",
1748            first_positive.energy
1749        );
1750        assert!(
1751            (first_positive.j - 0.5).abs() < 1e-10,
1752            "6.674 eV resonance has J=0.5"
1753        );
1754
1755        // The 6.674 eV resonance neutron width: ~1.493e-3 eV
1756        assert!(
1757            (first_positive.gn - 1.493e-3).abs() < 1e-5,
1758            "Neutron width should be ~1.493e-3 eV, got {}",
1759            first_positive.gn
1760        );
1761        // Gamma width: ~2.3e-2 eV
1762        assert!(
1763            (first_positive.gg - 2.3e-2).abs() < 1e-3,
1764            "Gamma width should be ~2.3e-2 eV, got {}",
1765            first_positive.gg
1766        );
1767
1768        let total = data.total_resonance_count();
1769        println!(
1770            "U-238 ENDF parsed successfully: {} total resonances across {} L-groups",
1771            total,
1772            range.l_groups.len()
1773        );
1774        println!(
1775            "  L=0: {} resonances, L=1: {} resonances",
1776            l0.resonances.len(),
1777            range.l_groups[1].resonances.len()
1778        );
1779    }
1780
1781    /// Verify KRM=3 resonance column order (offline fixture — no network needed).
1782    ///
1783    /// For KRM=3 the per-resonance ENDF layout is [ER, Γγ, Γ_1, ..., Γ_NCH, padding].
1784    /// The regression checks that `gamma_gamma` comes from position b+1 (Γγ) and
1785    /// `widths[0]` from position b+2 (Γ_1), NOT the other way round.
1786    ///
1787    /// Constructed values:
1788    ///   res0: ER=10 eV, Γγ=0.025 eV, Γ_1=0.001 eV
1789    ///   res1: ER=20 eV, Γγ=0.030 eV, Γ_1=0.002 eV
1790    ///
1791    /// The fixture is a minimal but fully valid ENDF MF=2/MT=151 block:
1792    ///   1 isotope, 1 energy range, LRF=7, KRM=3, 1 particle pair, 1 spin group,
1793    ///   2 resonances, NCH=1 (single elastic neutron channel).
1794    #[test]
1795    fn test_krm3_resonance_column_order() {
1796        // Each ENDF line is exactly 80 chars:
1797        //   positions  0-65: six 11-char data fields
1798        //   positions 66-69: MAT (4 chars)
1799        //   positions 70-71: MF (2 chars)
1800        //   positions 72-74: MT (3 chars)
1801        //   positions 75-79: NS (5 chars)
1802        //
1803        // Floats use Fortran notation, e.g. "1.000000+1" = 1e1 = 10.0.
1804        // Integer fields written as right-justified 11-char strings.
1805        const ENDF: &str = concat!(
1806            // ── HEAD: ZA=74184, AWR=182, NIS=1 ──────────────────────────────
1807            " 7.418400+4 1.820000+2          0          0          1          07437 2151    1\n",
1808            // ── Isotope CONT: NER=1 ──────────────────────────────────────────
1809            " 7.418400+4 1.000000+0          0          0          1          07437 2151    2\n",
1810            // ── Range CONT: EL=1e-5, EH=1e3, LRU=1, LRF=7, NRO=0, NAPS=0 ──
1811            " 1.000000-5 1.000000+3          1          7          0          07437 2151    3\n",
1812            // ── LRF=7 CONT: SPI=0, AP=0.7, IFG=0, KRM=3, NJS=1, KRL=0 ─────
1813            " 0.000000+0 7.000000-1          0          3          1          07437 2151    4\n",
1814            // ── Particle-pair LIST CONT: NPP=1, NPL=12 ───────────────────────
1815            " 0.000000+0 0.000000+0          1          0         12          17437 2151    5\n",
1816            // Particle pair 1: MA=1, MB=182, ZA=0, ZB=0, IA=0.5, IB=0
1817            " 1.000000+0 1.820000+2 0.000000+0 0.000000+0 5.000000-1 0.000000+07437 2151    6\n",
1818            // Q=0, PNT=1, SHF=0, MT=2, PA=1, PB=1
1819            " 0.000000+0 1.000000+0 0.000000+0 2.000000+0 1.000000+0 1.000000+07437 2151    7\n",
1820            // ── Spin-group LIST CONT: AJ=0.5, KBK=0, KPS=0, NPL=12, NCH+1=2
1821            " 5.000000-1 0.000000+0          0          0         12          27437 2151    8\n",
1822            // Header row (6 zeros, ignored by parser)
1823            " 0.000000+0 0.000000+0 0.000000+0 0.000000+0 0.000000+0 0.000000+07437 2151    9\n",
1824            // Channel 0: IPP=1, L=0, SCH=0.5, BND=0, APE=0.7, APT=0.7
1825            " 1.000000+0 0.000000+0 5.000000-1 0.000000+0 7.000000-1 7.000000-17437 2151   10\n",
1826            // ── Resonance LIST CONT: NPL=12, NRS=2 ───────────────────────────
1827            " 0.000000+0 0.000000+0          0          0         12          27437 2151   11\n",
1828            // res0: ER=10 eV, Γγ=0.025 eV, Γ_1=0.001 eV, (3 padding zeros)
1829            " 1.000000+1 2.500000-2 1.000000-3 0.000000+0 0.000000+0 0.000000+07437 2151   12\n",
1830            // res1: ER=20 eV, Γγ=0.030 eV, Γ_1=0.002 eV, (3 padding zeros)
1831            " 2.000000+1 3.000000-2 2.000000-3 0.000000+0 0.000000+0 0.000000+07437 2151   13\n",
1832        );
1833
1834        let data = parse_endf_file2(ENDF).expect("fixture must parse without error");
1835        let rml = data.ranges[0]
1836            .rml
1837            .as_ref()
1838            .expect("LRF=7 range must have RmlData");
1839        let sg = &rml.spin_groups[0];
1840
1841        assert_eq!(sg.resonances.len(), 2, "spin group must have 2 resonances");
1842
1843        let res0 = &sg.resonances[0];
1844        assert!(
1845            (res0.energy - 10.0).abs() < 1e-10,
1846            "res0 energy must be 10.0 eV, got {}",
1847            res0.energy
1848        );
1849        // The critical assertions: Γγ must come from column b+1, Γ_1 from column b+2.
1850        // With the old (buggy) code these two values were swapped.
1851        assert!(
1852            (res0.gamma_gamma - 0.025).abs() < 1e-10,
1853            "res0 gamma_gamma must be 0.025 eV (Gamgam at b+1), got {}",
1854            res0.gamma_gamma
1855        );
1856        assert_eq!(res0.widths.len(), 1, "NCH=1 so widths must have 1 element");
1857        assert!(
1858            (res0.widths[0] - 0.001).abs() < 1e-10,
1859            "res0 widths[0] must be 0.001 eV (Γ_1 at b+2), got {}",
1860            res0.widths[0]
1861        );
1862
1863        let res1 = &sg.resonances[1];
1864        assert!(
1865            (res1.energy - 20.0).abs() < 1e-10,
1866            "res1 energy must be 20.0 eV"
1867        );
1868        assert!(
1869            (res1.gamma_gamma - 0.030).abs() < 1e-10,
1870            "res1 gamma_gamma must be 0.030 eV, got {}",
1871            res1.gamma_gamma
1872        );
1873        assert!(
1874            (res1.widths[0] - 0.002).abs() < 1e-10,
1875            "res1 widths[0] must be 0.002 eV, got {}",
1876            res1.widths[0]
1877        );
1878    }
1879
1880    /// KRM=2 spin group with an explicit photon capture channel (IPP=2, MA=0).
1881    ///
1882    /// Before issue #45 the parser rejected MA<0.5 channels with UnsupportedFormat.
1883    /// This test verifies that photon channels are now parsed and stored correctly:
1884    ///   - channels[1] points to the photon particle pair (MT=102)
1885    ///   - res.widths has two entries: [γ_elastic, γ_photon]
1886    #[test]
1887    fn test_krm2_explicit_photon_channel() {
1888        // Minimal synthetic LRF=7, KRM=2, NJS=1 ENDF snippet.
1889        // Two particle pairs: pair 1 = n+W184 (MT=2), pair 2 = γ+W185 (MT=102, MA=0).
1890        // One spin group with 2 channels (elastic + photon); one resonance.
1891        //
1892        // Each ENDF line is 80 chars: 6×11-char fields + MAT(4)+MF(2)+MT(3)+NS(5).
1893        const ENDF: &str = concat!(
1894            // ── HEAD: ZA=74184, AWR=182, NIS=1 ─────────────────────────────────
1895            " 7.418400+4 1.820000+2          0          0          1          07437 2151    1\n",
1896            // ── Isotope CONT: NER=1 ─────────────────────────────────────────────
1897            " 7.418400+4 1.000000+0          0          0          1          07437 2151    2\n",
1898            // ── Range CONT: LRU=1, LRF=7, NRO=0 ────────────────────────────────
1899            " 1.000000-5 1.000000+3          1          7          0          07437 2151    3\n",
1900            // ── LRF=7 CONT: SPI=0, AP=0.7, IFG=0, KRM=2, NJS=1, KRL=0 ─────────
1901            " 0.000000+0 7.000000-1          0          2          1          07437 2151    4\n",
1902            // ── Particle-pair LIST CONT: NPP=2 in L1, N1=24, N2=2 ───────────────
1903            " 0.000000+0 0.000000+0          2          0         24          27437 2151    5\n",
1904            // Pair 1 (neutron+W184): MA=1, MB=182, ZA=0, ZB=0, IA=0.5, IB=0
1905            " 1.000000+0 1.820000+2 0.000000+0 0.000000+0 5.000000-1 0.000000+07437 2151    6\n",
1906            // Q=0, PNT=1, SHF=0, MT=2, PA=1, PB=1
1907            " 0.000000+0 1.000000+0 0.000000+0 2.000000+0 1.000000+0 1.000000+07437 2151    7\n",
1908            // Pair 2 (photon+W185): MA=0 (massless), MB=183, ZA=0, ZB=0, IA=0, IB=0.5
1909            " 0.000000+0 1.830000+2 0.000000+0 0.000000+0 0.000000+0 5.000000-17437 2151    8\n",
1910            // Q=6e6 eV (binding), PNT=0, SHF=0, MT=102 (capture), PA=1, PB=1
1911            " 6.000000+6 0.000000+0 0.000000+0 1.020000+2 1.000000+0 1.000000+07437 2151    9\n",
1912            // ── Spin-group LIST CONT: AJ=0.5, KBK=0, KPS=0, NPL=18, NCH+1=3 ────
1913            " 5.000000-1 0.000000+0          0          0         18          37437 2151   10\n",
1914            // Header row (6 zeros)
1915            " 0.000000+0 0.000000+0 0.000000+0 0.000000+0 0.000000+0 0.000000+07437 2151   11\n",
1916            // Channel 0 (elastic): IPP=1, L=0, SCH=0.5, BND=0, APE=0.7, APT=0.7
1917            " 1.000000+0 0.000000+0 5.000000-1 0.000000+0 7.000000-1 7.000000-17437 2151   12\n",
1918            // Channel 1 (photon): IPP=2, L=0, SCH=0, BND=0, APE=0, APT=0
1919            " 2.000000+0 0.000000+0 0.000000+0 0.000000+0 0.000000+0 0.000000+07437 2151   13\n",
1920            // ── Resonance LIST CONT: NPL=6, NRS=1 ───────────────────────────────
1921            " 0.000000+0 0.000000+0          0          0          6          17437 2151   14\n",
1922            // res0: ER=10 eV, γ_elastic=0.001, γ_photon=0.004, 3 padding zeros
1923            " 1.000000+1 1.000000-3 4.000000-3 0.000000+0 0.000000+0 0.000000+07437 2151   15\n",
1924        );
1925
1926        let data = parse_endf_file2(ENDF).expect("KRM=2 photon channel must parse without error");
1927        let rml = data.ranges[0]
1928            .rml
1929            .as_ref()
1930            .expect("LRF=7 range must have RmlData");
1931
1932        assert_eq!(rml.krm, 2, "KRM must be 2");
1933        assert_eq!(rml.particle_pairs.len(), 2, "must have 2 particle pairs");
1934        assert!(
1935            rml.particle_pairs[1].ma < 0.5,
1936            "pair 2 must be massless (photon)"
1937        );
1938        assert_eq!(
1939            rml.particle_pairs[1].mt, 102,
1940            "pair 2 must be MT=102 capture"
1941        );
1942
1943        let sg = &rml.spin_groups[0];
1944        assert_eq!(sg.channels.len(), 2, "spin group must have 2 channels");
1945        assert_eq!(
1946            sg.channels[0].particle_pair_idx, 0,
1947            "channel 0 must point to pair 0 (elastic)"
1948        );
1949        assert_eq!(
1950            sg.channels[1].particle_pair_idx, 1,
1951            "channel 1 must point to pair 1 (photon)"
1952        );
1953
1954        assert_eq!(sg.resonances.len(), 1, "must have 1 resonance");
1955        let res = &sg.resonances[0];
1956        assert!((res.energy - 10.0).abs() < 1e-10, "energy must be 10 eV");
1957        assert_eq!(res.widths.len(), 2, "widths must have 2 entries (NCH=2)");
1958        assert!(
1959            (res.widths[0] - 0.001).abs() < 1e-10,
1960            "widths[0] (elastic) must be 0.001, got {}",
1961            res.widths[0]
1962        );
1963        assert!(
1964            (res.widths[1] - 0.004).abs() < 1e-10,
1965            "widths[1] (photon) must be 0.004, got {}",
1966            res.widths[1]
1967        );
1968    }
1969
1970    /// Parse a real LRF=7 ENDF file (W-184) downloaded from IAEA.
1971    ///
1972    /// Run with: cargo test -p nereids-endf -- --ignored test_parse_w184_rml
1973    ///
1974    /// Validates: formalism == RMatrixLimited, spin groups non-empty,
1975    /// first positive resonance energy in plausible range (W-184: ~101.9 eV).
1976    #[test]
1977    #[ignore = "requires network: downloads W-184 ENDF from IAEA (~50 kB)"]
1978    fn test_parse_w184_rml() {
1979        use crate::retrieval::{EndfLibrary, EndfRetriever};
1980        use nereids_core::types::Isotope;
1981
1982        let retriever = EndfRetriever::new();
1983        let isotope = Isotope::new(74, 184).unwrap();
1984        let (_, text) = retriever
1985            .get_endf_file(&isotope, EndfLibrary::EndfB8_0, 7437)
1986            .expect("Failed to download W-184 ENDF/B-VIII.0");
1987
1988        let data = parse_endf_file2(&text).expect("Failed to parse W-184 ENDF");
1989
1990        assert!(
1991            !data.ranges.is_empty(),
1992            "W-184 should have at least one energy range"
1993        );
1994        let range = &data.ranges[0];
1995        assert_eq!(
1996            range.formalism,
1997            crate::resonance::ResonanceFormalism::RMatrixLimited,
1998            "W-184 uses LRF=7 (R-Matrix Limited) in ENDF/B-VIII.0"
1999        );
2000
2001        let rml = range.rml.as_ref().expect("LRF=7 range should have RmlData");
2002        assert!(!rml.particle_pairs.is_empty(), "Should have particle pairs");
2003        assert!(!rml.spin_groups.is_empty(), "Should have spin groups");
2004
2005        let total_resonances: usize = rml.spin_groups.iter().map(|sg| sg.resonances.len()).sum();
2006        assert!(
2007            total_resonances > 10,
2008            "W-184 should have many resonances, got {total_resonances}"
2009        );
2010
2011        // First positive-energy resonance in W-184 ENDF/B-VIII.0 (KRM=3, J=1/2+ group)
2012        // is at ~101.95 eV.  The previously assumed 7.6 eV belongs to W-182, not W-184.
2013        let first_pos_e = rml
2014            .spin_groups
2015            .iter()
2016            .flat_map(|sg| &sg.resonances)
2017            .map(|r| r.energy)
2018            .filter(|&e| e > 0.0)
2019            .fold(f64::MAX, f64::min);
2020        assert!(
2021            first_pos_e > 50.0 && first_pos_e < 200.0,
2022            "First W-184 positive resonance expected ~101.95 eV, got {first_pos_e:.2} eV"
2023        );
2024
2025        println!(
2026            "W-184 LRF=7 parsed: {} spin groups, {} total resonances, \
2027             first resonance at {:.2} eV",
2028            rml.spin_groups.len(),
2029            total_resonances,
2030            first_pos_e
2031        );
2032    }
2033
2034    /// Parse a minimal hand-crafted ENDF snippet with NRO=1 (energy-dependent
2035    /// scattering radius).
2036    ///
2037    /// The fixture encodes:
2038    /// - LRF=3 (Reich-Moore), NRO=1
2039    /// - AP TAB1: 2 points — ENDF values 8.0 and 10.0 (10⁻¹² cm),
2040    ///   which become 80.0 fm and 100.0 fm after ×10 conversion
2041    /// - One L-group (L=0) with one resonance at 6.674 eV
2042    ///
2043    /// Verifies:
2044    /// - ap_table is Some after parsing
2045    /// - ap_table.evaluate(1.0) ≈ 80.0 fm (8.0 × ENDF_RADIUS_TO_FM)
2046    /// - ap_table.evaluate(500.5) ≈ 90.0 fm (midpoint, lin-lin)
2047    /// - ap_table.evaluate(1000.0) ≈ 100.0 fm
2048    /// - scattering_radius_at() delegates to the table
2049    #[test]
2050    fn test_parse_nro1_tab1() {
2051        // Each ENDF line is exactly 80 chars: 66 data chars + 14 MAT/MF/MT/SEQ.
2052        // Cols 67-70: MAT=9237, Cols 71-72: MF=2, Cols 73-75: MT=151, Cols 76-80: seq
2053        //
2054        // Line layout (11 chars per field × 6 fields = 66 chars, then 14 control chars):
2055        //   HEAD:  ZA=92238  AWR=236.006  0  0  NIS=1  0
2056        //   CONT:  ZAI=92238 ABN=1.0      0  LFW=0 NER=1  0
2057        //   CONT:  EL=1e-5   EH=1e4    LRU=1  LRF=3  NRO=1  NAPS=0
2058        //   TAB1 CONT: 0  0  0  0  NR=1  NP=2
2059        //   TAB1 interp: NBT=2, INT=2  (plus 4 padding zeros)
2060        //   TAB1 data:   (1.0, 8.0), (1000.0, 10.0)
2061        //   RM CONT:  SPI=0.0  AP=9.0  0  0  NLS=1  0
2062        //   L CONT:  AWRI=236.006  0  L=0  0  6*NRS=6  NRS=1
2063        //   Resonance: ER=6.674  AJ=0.5  GN=1.493e-3  GG=23e-3  GFA=0  GFB=0
2064        //   SEND: all zeros
2065        // Each ENDF line: 66 data chars + 4-char MAT(9237) + 2-char MF(" 2")
2066        //   + 3-char MT("151") + 5-char SEQ = 80 chars total.
2067        let endf = concat!(
2068            // HEAD: ZA=92238, AWR=236.006, 0, 0, NIS=1, 0
2069            " 9.223800+4 2.360060+2          0          0          1          09237 2151    1\n",
2070            // Isotope CONT: ZAI=92238, ABN=1.0, 0, LFW=0, NER=1, 0
2071            " 9.223800+4 1.000000+0          0          0          1          09237 2151    2\n",
2072            // Range CONT: EL=1e-5, EH=1e4, LRU=1, LRF=3, NRO=1, NAPS=0
2073            " 1.000000-5 1.000000+4          1          3          1          09237 2151    3\n",
2074            // TAB1 CONT: C1=0, C2=0, L1=0, L2=0, NR=1, NP=2
2075            " 0.000000+0 0.000000+0          0          0          1          29237 2151    4\n",
2076            // TAB1 interp: NBT=2, INT=2 (4 padding zeros fill the 6-field line)
2077            "          2          2          0          0          0          09237 2151    5\n",
2078            // TAB1 data: (1.0, 8.0), (1000.0, 10.0); remaining 2 slots are padding
2079            " 1.000000+0 8.000000+0 1.000000+3 1.000000+1 0.000000+0 0.000000+09237 2151    6\n",
2080            // RM CONT: SPI=0.0, AP=9.0, 0, 0, NLS=1, 0
2081            " 0.000000+0 9.000000+0          0          0          1          09237 2151    7\n",
2082            // L CONT: AWRI=236.006, 0, L=0, 0, 6*NRS=6, NRS=1
2083            " 2.360060+2 0.000000+0          0          0          6          19237 2151    8\n",
2084            // Resonance: ER=6.674, AJ=0.5, GN=1.493e-3, GG=23e-3, GFA=0, GFB=0
2085            " 6.674000+0 5.000000-1 1.493000-3 2.300000-2 0.000000+0 0.000000+09237 2151    9\n",
2086        );
2087
2088        let data = parse_endf_file2(endf).expect("NRO=1 fixture must parse cleanly");
2089        assert_eq!(data.ranges.len(), 1, "one energy range");
2090
2091        let range = &data.ranges[0];
2092        assert_eq!(
2093            range.formalism,
2094            ResonanceFormalism::ReichMoore,
2095            "must be LRF=3"
2096        );
2097
2098        let table = range
2099            .ap_table
2100            .as_ref()
2101            .expect("NRO=1 range must have ap_table");
2102        assert_eq!(table.points.len(), 2, "TAB1 must have 2 points");
2103
2104        // Exact boundary values (ENDF 8.0 × 10 = 80.0 fm).
2105        assert!(
2106            (table.evaluate(1.0) - 80.0).abs() < 1e-10,
2107            "AP(1 eV) = 80.0 fm"
2108        );
2109        assert!(
2110            (table.evaluate(1000.0) - 100.0).abs() < 1e-10,
2111            "AP(1000 eV) = 100.0 fm"
2112        );
2113        // Lin-lin midpoint: AP(500.5 eV) ≈ 90.0 fm.
2114        let mid = table.evaluate(500.5);
2115        assert!((mid - 90.0).abs() < 0.1, "AP midpoint ≈ 90.0 fm, got {mid}");
2116
2117        // scattering_radius_at delegates to the table.
2118        assert!(
2119            (range.scattering_radius_at(1.0) - 80.0).abs() < 1e-10,
2120            "scattering_radius_at(1 eV) = 80.0"
2121        );
2122        assert!(
2123            (range.scattering_radius_at(1000.0) - 100.0).abs() < 1e-10,
2124            "scattering_radius_at(1000 eV) = 100.0"
2125        );
2126
2127        // Resonance is still parsed correctly.
2128        assert_eq!(range.l_groups.len(), 1, "one L-group");
2129        let res = &range.l_groups[0].resonances[0];
2130        assert!((res.energy - 6.674).abs() < 1e-6);
2131    }
2132
2133    /// Parse U-233 ENDF (SAMMY test tr149) which has LFW=1 in its URR.
2134    ///
2135    /// U-233 has two energy ranges:
2136    ///   - Range 0: LRU=1 (resolved, Reich-Moore / LRF=3)
2137    ///   - Range 1: LRU=2, LRF=2, **LFW=1** (energy-dependent fission widths)
2138    ///
2139    /// The LFW=1 URR range is gracefully skipped (not parsed into UrrData)
2140    /// because we don't yet support energy-dependent fission widths. The
2141    /// resolved range must still be returned so fissile isotopes remain
2142    /// usable for cross-section calculations.
2143    ///
2144    /// Test data: ../SAMMY/SAMMY/sammy/samtry/tr149/t149a.endf (MAT=9222, ZA=92233)
2145    #[test]
2146    fn test_parse_u233_lfw1_urr_parsed() {
2147        let endf_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2148            .parent()
2149            .unwrap()
2150            .parent()
2151            .unwrap()
2152            .join("../SAMMY/SAMMY/sammy/samtry/tr149/t149a.endf");
2153
2154        if !endf_path.exists() {
2155            eprintln!(
2156                "Skipping test: U-233 ENDF file not found at {:?}",
2157                endf_path
2158            );
2159            return;
2160        }
2161
2162        let text = std::fs::read_to_string(&endf_path).unwrap();
2163        let data = parse_endf_file2(&text).expect("U-233 must parse (LFW=1 URR now supported)");
2164
2165        // The resolved range must be present.
2166        assert!(
2167            !data.ranges.is_empty(),
2168            "U-233 must have at least one parsed range"
2169        );
2170
2171        // LFW=1 URR is now parsed — check if the URR range is present.
2172        // U-233 may have LFW=1/LRF=1 which is now handled by parse_urr_lfw1_lrf1,
2173        // or LFW=1/LRF=2 which passes through the standard parser.
2174        let urr_count = data.ranges.iter().filter(|r| r.urr.is_some()).count();
2175        assert!(
2176            urr_count >= 1,
2177            "U-233 LFW=1 URR range should now be parsed, found {urr_count} URR ranges"
2178        );
2179
2180        // At least one resolved range must exist.
2181        let resolved_count = data.ranges.iter().filter(|r| r.resolved).count();
2182        assert!(
2183            resolved_count >= 1,
2184            "U-233 must have at least one resolved range, found {resolved_count}"
2185        );
2186    }
2187
2188    /// Hand-crafted LRF=1 URR roundtrip test.
2189    ///
2190    /// Verifies that a minimal synthetic ENDF snippet with LRU=2, LRF=1 is
2191    /// parsed correctly: one L-group (L=0), two J-groups with known D, AJ,
2192    /// AMUN, GNO, GG, GF values.
2193    #[test]
2194    fn test_parse_lrf1_urr_roundtrip() {
2195        // Minimal ENDF MF=2/MT=151 with one resolved range followed by one
2196        // LRU=2 LRF=1 unresolved range.
2197        //
2198        // Resolved range: a simple RM LRF=3 with one resonance (gives the
2199        // parser something valid to consume before the URR section).
2200        //
2201        // URR range: LRU=2, LRF=1, NLS=1 (L=0), NJS=2 J-groups.
2202        //   J=2.0: D=0.5 eV, AMUN=1, GNO=3e-4 eV, GG=3.5e-2 eV, GF=0
2203        //   J=3.0: D=0.4 eV, AMUN=1, GNO=2e-4 eV, GG=3.0e-2 eV, GF=1e-3 eV
2204        //
2205        // Each ENDF line: 66 data chars + MAT(4) MF(2) MT(3) SEQ(5) = 80 chars.
2206        const ENDF: &str = concat!(
2207            // ── HEAD: ZA=92233, AWR=231.038, NIS=1 ─────────────────────────
2208            " 9.223300+4 2.310380+2          0          0          1          09222 2151    1\n",
2209            // ── Isotope CONT: ZAI=92233, ABN=1.0, LFW=0, NER=2 ─────────────
2210            " 9.223300+4 1.000000+0          0          0          2          09222 2151    2\n",
2211            // ── Range 0: EL=1e-5, EH=600, LRU=1, LRF=3 (resolved RM) ───────
2212            " 1.000000-5 6.000000+2          1          3          0          09222 2151    3\n",
2213            // RM CONT: SPI=2.5, AP=0.96931, NLS=1
2214            " 2.500000+0 9.693100-1          0          0          1          09222 2151    4\n",
2215            // L CONT: AWRI=231.038, APL=0, L=0, NRS=1
2216            " 2.310380+2 0.000000+0          0          0          6          19222 2151    5\n",
2217            // One resonance: ER=10 eV, AJ=2.0, GN=1e-3, GG=3.5e-2, GFA=0, GFB=0
2218            " 1.000000+1 2.000000+0 1.000000-3 3.500000-2 0.000000+0 0.000000+09222 2151    6\n",
2219            // ── Range 1: EL=600, EH=3e4, LRU=2, LRF=1 (URR) ────────────────
2220            " 6.000000+2 3.000000+4          2          1          0          09222 2151    7\n",
2221            // URR CONT: SPI=2.5, AP=0.96931, NLS=1
2222            " 2.500000+0 9.693100-1          0          0          1          09222 2151    8\n",
2223            // L=0 CONT: AWRI=231.038, L=0, N1=12(=6*NJS), N2=2(=NJS)
2224            " 2.310380+2 0.000000+0          0          0         12          29222 2151    9\n",
2225            // J=2.0: D=0.5,  AJ=2.0, AMUN=1.0, GNO=3e-4,  GG=3.5e-2, GF=0
2226            " 5.000000-1 2.000000+0 1.000000+0 3.000000-4 3.500000-2 0.000000+09222 2151   10\n",
2227            // J=3.0: D=0.4,  AJ=3.0, AMUN=1.0, GNO=2e-4,  GG=3.0e-2, GF=1e-3
2228            " 4.000000-1 3.000000+0 1.000000+0 2.000000-4 3.000000-2 1.000000-39222 2151   11\n",
2229        );
2230
2231        let data = parse_endf_file2(ENDF).expect("LRF=1 URR fixture must parse cleanly");
2232
2233        // Should have 2 ranges: one resolved + one URR.
2234        assert_eq!(data.ranges.len(), 2, "must have 2 ranges");
2235
2236        let urr_range = &data.ranges[1];
2237        assert!(!urr_range.resolved, "URR range must not be resolved");
2238        assert_eq!(
2239            urr_range.formalism,
2240            ResonanceFormalism::Unresolved,
2241            "formalism must be Unresolved"
2242        );
2243
2244        let urr = urr_range
2245            .urr
2246            .as_ref()
2247            .expect("URR range must have urr data");
2248        assert_eq!(urr.lrf, 1, "LRF must be 1");
2249        assert!((urr.spi - 2.5).abs() < 1e-10, "SPI must be 2.5");
2250        assert!((urr.e_low - 600.0).abs() < 1.0, "e_low must be 600 eV");
2251        assert!(
2252            (urr.e_high - 30_000.0).abs() < 1.0,
2253            "e_high must be 30 000 eV"
2254        );
2255
2256        assert_eq!(urr.l_groups.len(), 1, "must have 1 L-group");
2257        let lg = &urr.l_groups[0];
2258        assert_eq!(lg.l, 0, "L must be 0");
2259        assert!((lg.awri - 231.038).abs() < 0.001, "AWRI must be 231.038");
2260        assert_eq!(lg.j_groups.len(), 2, "must have 2 J-groups");
2261
2262        let jg0 = &lg.j_groups[0];
2263        assert!((jg0.j - 2.0).abs() < 1e-10, "first J must be 2.0");
2264        assert!(jg0.energies.is_empty(), "LRF=1 energies must be empty");
2265        assert!((jg0.d[0] - 0.5).abs() < 1e-10, "D must be 0.5 eV");
2266        assert!((jg0.amun - 1.0).abs() < 1e-10, "AMUN must be 1.0");
2267        assert!((jg0.gn[0] - 3e-4).abs() < 1e-14, "GNO must be 3e-4 eV");
2268        assert!((jg0.gg[0] - 3.5e-2).abs() < 1e-12, "GG must be 3.5e-2 eV");
2269        assert!((jg0.gf[0] - 0.0).abs() < 1e-14, "GF must be 0");
2270
2271        let jg1 = &lg.j_groups[1];
2272        assert!((jg1.j - 3.0).abs() < 1e-10, "second J must be 3.0");
2273        assert!((jg1.d[0] - 0.4).abs() < 1e-10, "D must be 0.4 eV");
2274        assert!((jg1.gn[0] - 2e-4).abs() < 1e-14, "GNO must be 2e-4 eV");
2275        assert!((jg1.gf[0] - 1e-3).abs() < 1e-14, "GF must be 1e-3 eV");
2276    }
2277
2278    // -----------------------------------------------------------------------
2279    // Issue #123: robustness tests for malformed input validation
2280    // -----------------------------------------------------------------------
2281
2282    /// `checked_count` rejects negative values.
2283    #[test]
2284    fn test_checked_count_negative() {
2285        let err = checked_count(-1, "NLS").unwrap_err();
2286        assert!(
2287            err.to_string().contains("Negative"),
2288            "expected negative error, got: {err}"
2289        );
2290    }
2291
2292    /// `checked_count` rejects values above `MAX_ENDF_COUNT` to prevent
2293    /// allocation bombs from malformed files.
2294    #[test]
2295    fn test_checked_count_upper_bound() {
2296        // Just above the limit.
2297        let err = checked_count(MAX_ENDF_COUNT + 1, "NRS").unwrap_err();
2298        assert!(
2299            err.to_string().contains("too large"),
2300            "expected upper-bound error, got: {err}"
2301        );
2302
2303        // At the limit: should succeed.
2304        assert_eq!(checked_count(MAX_ENDF_COUNT, "NRS").unwrap(), 1_000_000);
2305
2306        // i32::MAX: should be rejected.
2307        let err = checked_count(i32::MAX, "NPL").unwrap_err();
2308        assert!(
2309            err.to_string().contains("too large"),
2310            "expected upper-bound error for i32::MAX, got: {err}"
2311        );
2312    }
2313
2314    /// Negative L-value in a Breit-Wigner range is rejected.
2315    ///
2316    /// Constructs a minimal SLBW fixture with L=-1 in the L-group CONT record.
2317    /// Without the validation, `l_cont.l1 as u32` would wrap to `u32::MAX`.
2318    #[test]
2319    fn test_bw_negative_l_rejected() {
2320        // Minimal SLBW fixture: HEAD + isotope CONT + range CONT + SPI/AP CONT +
2321        // L-group CONT with L=-1 (field 3 = -1).
2322        const ENDF: &str = concat!(
2323            // HEAD: ZA=92238, AWR=236, NIS=1
2324            " 9.223800+4 2.360000+2          0          0          1          09237 2151    1\n",
2325            // Isotope CONT: NER=1
2326            " 9.223800+4 1.000000+0          0          0          1          09237 2151    2\n",
2327            // Range CONT: LRU=1, LRF=1 (SLBW), NRO=0, NAPS=0
2328            " 1.000000-5 1.000000+4          1          1          0          09237 2151    3\n",
2329            // SPI/AP CONT: SPI=0, AP=9.4, NLS=1
2330            " 0.000000+0 9.400000-1          0          0          1          09237 2151    4\n",
2331            // L-group CONT: AWRI=236, QX=0, L=-1 (invalid), LRX=0, N1=6, NRS=1
2332            " 2.360000+2 0.000000+0         -1          0          6          19237 2151    5\n",
2333            // Resonance data (won't be reached)
2334            " 6.674000+0 5.000000-1 2.500000-2 1.493000-3 2.300000-2 0.000000+09237 2151    6\n",
2335        );
2336
2337        let err = parse_endf_file2(ENDF).unwrap_err();
2338        assert!(
2339            err.to_string().contains("negative L"),
2340            "expected negative L error, got: {err}"
2341        );
2342    }
2343
2344    /// Negative L-value in a Reich-Moore range is rejected.
2345    #[test]
2346    fn test_rm_negative_l_rejected() {
2347        const ENDF: &str = concat!(
2348            // HEAD: ZA=92238, AWR=236, NIS=1
2349            " 9.223800+4 2.360000+2          0          0          1          09237 2151    1\n",
2350            // Isotope CONT: NER=1
2351            " 9.223800+4 1.000000+0          0          0          1          09237 2151    2\n",
2352            // Range CONT: LRU=1, LRF=3 (Reich-Moore), NRO=0, NAPS=0
2353            " 1.000000-5 1.000000+4          1          3          0          09237 2151    3\n",
2354            // SPI/AP CONT: SPI=0, AP=9.4, NLS=1
2355            " 0.000000+0 9.400000-1          0          0          1          09237 2151    4\n",
2356            // L-group CONT: AWRI=236, APL=0, L=-2 (invalid), 0, N1=6, NRS=1
2357            " 2.360000+2 0.000000+0         -2          0          6          19237 2151    5\n",
2358            // Resonance data (won't be reached)
2359            " 6.674000+0 5.000000-1 1.493000-3 2.300000-2 0.000000+0 0.000000+09237 2151    6\n",
2360        );
2361
2362        let err = parse_endf_file2(ENDF).unwrap_err();
2363        assert!(
2364            err.to_string().contains("negative L"),
2365            "expected negative L error, got: {err}"
2366        );
2367    }
2368
2369    /// LRU=0 range with non-zero NLS is rejected.
2370    ///
2371    /// ENDF-6 §2.2 says the SPI/AP CONT after an LRU=0 range must have
2372    /// NLS=0 (no L-groups for scattering-radius-only ranges).
2373    #[test]
2374    fn test_lru0_nonzero_nls_rejected() {
2375        const ENDF: &str = concat!(
2376            // HEAD: ZA=92238, AWR=236, NIS=1
2377            " 9.223800+4 2.360000+2          0          0          1          09237 2151    1\n",
2378            // Isotope CONT: NER=1
2379            " 9.223800+4 1.000000+0          0          0          1          09237 2151    2\n",
2380            // Range CONT: LRU=0 (scattering-radius-only), LRF=0, NRO=0, NAPS=0
2381            " 1.000000-5 1.000000+4          0          0          0          09237 2151    3\n",
2382            // SPI/AP CONT: SPI=0, AP=9.4, L1=0, L2=0, NLS=3 (invalid!), N2=0
2383            " 0.000000+0 9.400000-1          0          0          3          09237 2151    4\n",
2384        );
2385
2386        let err = parse_endf_file2(ENDF).unwrap_err();
2387        assert!(
2388            err.to_string().contains("NLS=3"),
2389            "expected LRU=0 NLS validation error, got: {err}"
2390        );
2391    }
2392
2393    /// LFW=1 (energy-dependent fission widths) URR is gracefully skipped.
2394    ///
2395    /// ENDF-6 §2.2.2.1 Case B: LFW=1, LRF=1 has a shared energy grid
2396    /// followed by per-J LIST blocks of NE+6 values.  The parser skips this
2397    /// layout correctly and returns no URR data.
2398    ///
2399    /// This synthetic snippet has: NE=2, NLS=1, NJS=1.
2400    #[test]
2401    fn test_lfw1_urr_gracefully_skipped() {
2402        const ENDF: &str = concat!(
2403            // HEAD: ZA=92233, AWR=231.038, NIS=1
2404            " 9.223300+4 2.310380+2          0          0          1          09222 2151    1\n",
2405            // Isotope CONT: ZAI=92233, ABN=1.0, LFW=1, NER=1
2406            " 9.223300+4 1.000000+0          0          1          1          09222 2151    2\n",
2407            // Range CONT: EL=600, EH=3e4, LRU=2, LRF=1, NRO=0, NAPS=0
2408            " 6.000000+2 3.000000+4          2          1          0          09222 2151    3\n",
2409            // --- LFW=1, LRF=1 body (Case B) ---
2410            // CONT: SPI=3.5, AP=9.4, LSSF=0, 0, NE=2, NLS=1
2411            " 3.500000+0 9.400000-1          0          0          2          19222 2151    4\n",
2412            // LIST: 2 energy values (NE=2, padded to 6 per line)
2413            " 6.000000+2 3.000000+4 0.000000+0 0.000000+0 0.000000+0 0.000000+09222 2151    5\n",
2414            // L=0 CONT: AWRI=231, 0, L=0, 0, NJS=1, 0
2415            " 2.310380+2 0.000000+0          0          0          1          09222 2151    6\n",
2416            // J=3.0 LIST: 6 header + NE=2 fission widths = 8 values (→ 2 lines of 6)
2417            " 1.000000+1 3.000000+0 1.000000+0 5.000000-2 4.000000-2 0.000000+09222 2151    7\n",
2418            " 1.000000-1 2.000000-1 0.000000+0 0.000000+0 0.000000+0 0.000000+09222 2151    8\n",
2419            // SEND
2420            "                                                                  9222 0  0    0\n",
2421        );
2422
2423        let data = parse_endf_file2(ENDF).expect("LFW=1 URR parse should succeed");
2424        // LFW=1/LRF=1 is now fully parsed — URR data should be present.
2425        let urr_count = data.ranges.iter().filter(|r| r.urr.is_some()).count();
2426        assert_eq!(urr_count, 1, "LFW=1/LRF=1 URR should be parsed");
2427        let urr = data.ranges.iter().find(|r| r.urr.is_some()).unwrap();
2428        let urr_data = urr.urr.as_ref().unwrap();
2429        assert_eq!(urr_data.lrf, 1);
2430        assert_eq!(urr_data.l_groups.len(), 1);
2431        let jg = &urr_data.l_groups[0].j_groups[0];
2432        // Fission widths should be energy-dependent (2 values from NE=2)
2433        assert_eq!(jg.gf.len(), 2, "LFW=1 should have NE fission widths");
2434        assert!((jg.gf[0] - 0.1).abs() < 1e-6, "GF[0] = {}", jg.gf[0]);
2435        assert!((jg.gf[1] - 0.2).abs() < 1e-6, "GF[1] = {}", jg.gf[1]);
2436    }
2437
2438    /// LRU=0 range with non-zero L1 in SPI/AP CONT is rejected.
2439    ///
2440    /// ENDF-6 §2.2: the SPI/AP CONT record for LRU=0 must be
2441    /// [SPI, AP, 0, 0, NLS=0, 0].  Non-zero L1 or L2 indicates a
2442    /// malformed or mis-identified record.
2443    #[test]
2444    fn test_lru0_nonzero_l1_rejected() {
2445        const ENDF: &str = concat!(
2446            // HEAD: ZA=92238, AWR=236, NIS=1
2447            " 9.223800+4 2.360000+2          0          0          1          09237 2151    1\n",
2448            // Isotope CONT: NER=1
2449            " 9.223800+4 1.000000+0          0          0          1          09237 2151    2\n",
2450            // Range CONT: LRU=0 (scattering-radius-only), LRF=0, NRO=0, NAPS=0
2451            " 1.000000-5 1.000000+4          0          0          0          09237 2151    3\n",
2452            // SPI/AP CONT: SPI=0, AP=9.4, L1=5 (invalid!), L2=0, NLS=0, N2=0
2453            " 0.000000+0 9.400000-1          5          0          0          09237 2151    4\n",
2454        );
2455
2456        let err = parse_endf_file2(ENDF).unwrap_err();
2457        assert!(
2458            err.to_string().contains("L1=5"),
2459            "expected LRU=0 L1 validation error, got: {err}"
2460        );
2461    }
2462
2463    /// N1 != 6*NRS in a BW range CONT is rejected.
2464    #[test]
2465    fn test_bw_n1_nrs_mismatch_rejected() {
2466        const ENDF: &str = concat!(
2467            // HEAD: ZA=92238, AWR=236, NIS=1
2468            " 9.223800+4 2.360000+2          0          0          1          09237 2151    1\n",
2469            // Isotope CONT: NER=1
2470            " 9.223800+4 1.000000+0          0          0          1          09237 2151    2\n",
2471            // Range CONT: LRU=1, LRF=1 (SLBW), NRO=0, NAPS=0
2472            " 1.000000-5 1.000000+4          1          1          0          09237 2151    3\n",
2473            // SPI/AP CONT: SPI=0, AP=9.4, NLS=1
2474            " 0.000000+0 9.400000-1          0          0          1          09237 2151    4\n",
2475            // L-group CONT: AWRI=236, QX=0, L=0, LRX=0, N1=7 (should be 6), NRS=1
2476            " 2.360000+2 0.000000+0          0          0          7          19237 2151    5\n",
2477            // Resonance data (won't be fully consumed)
2478            " 6.674000+0 5.000000-1 2.500000-2 1.493000-3 2.300000-2 0.000000+09237 2151    6\n",
2479        );
2480
2481        let err = parse_endf_file2(ENDF).unwrap_err();
2482        assert!(
2483            err.to_string().contains("N1=7"),
2484            "expected N1/NRS mismatch error, got: {err}"
2485        );
2486    }
2487
2488    /// Multi-MAT detection: unconsumed MF=2/MT=151 lines after the first
2489    /// material are rejected.
2490    #[test]
2491    fn test_multi_mat_detection() {
2492        // A valid single-range SLBW file with an extra trailing data line
2493        // that still carries MF=2/MT=151 tags.
2494        const ENDF: &str = concat!(
2495            // HEAD: ZA=92238, AWR=236, NIS=1
2496            " 9.223800+4 2.360000+2          0          0          1          09237 2151    1\n",
2497            // Isotope CONT: NER=1
2498            " 9.223800+4 1.000000+0          0          0          1          09237 2151    2\n",
2499            // Range CONT: LRU=1, LRF=1 (SLBW), NRO=0, NAPS=0
2500            " 1.000000-5 1.000000+4          1          1          0          09237 2151    3\n",
2501            // SPI/AP CONT: SPI=0, AP=9.4, NLS=1
2502            " 0.000000+0 9.400000-1          0          0          1          09237 2151    4\n",
2503            // L-group CONT: AWRI=236, QX=0, L=0, LRX=0, N1=6, NRS=1
2504            " 2.360000+2 0.000000+0          0          0          6          19237 2151    5\n",
2505            // Resonance data
2506            " 6.674000+0 5.000000-1 2.500000-2 1.493000-3 2.300000-2 0.000000+09237 2151    6\n",
2507            // Extra unconsumed line (another material's HEAD)
2508            " 2.600500+4 2.503200+1          0          0          1          02631 2151    1\n",
2509        );
2510
2511        let err = parse_endf_file2(ENDF).unwrap_err();
2512        assert!(
2513            err.to_string().contains("Multiple materials"),
2514            "expected multi-MAT error, got: {err}"
2515        );
2516    }
2517
2518    /// Verify URR energy deduplication keeps the last occurrence.
2519    #[test]
2520    #[allow(clippy::useless_vec)] // Vecs needed for mutation (truncate/copy_within)
2521    fn test_urr_energy_dedup_keeps_last() {
2522        // Simulate the dedup logic on mock parallel arrays.
2523        let mut energies = vec![1.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0];
2524        let mut d = vec![10.0, 20.0, 21.0, 30.0, 31.0, 32.0, 40.0];
2525        let mut gx = vec![0.0; 7];
2526        let mut gn = vec![0.0; 7];
2527        let mut gg = vec![0.0; 7];
2528        let mut gf = vec![0.0; 7];
2529
2530        let n = energies.len();
2531        if n > 1 {
2532            let mut write = n - 1;
2533            let mut last_e = energies[n - 1];
2534            let mut read = n - 1;
2535            while read > 0 {
2536                read -= 1;
2537                if energies[read] == last_e {
2538                    continue;
2539                }
2540                write -= 1;
2541                energies[write] = energies[read];
2542                d[write] = d[read];
2543                gx[write] = gx[read];
2544                gn[write] = gn[read];
2545                gg[write] = gg[read];
2546                gf[write] = gf[read];
2547                last_e = energies[read];
2548            }
2549            let new_len = n - write;
2550            energies.copy_within(write..n, 0);
2551            d.copy_within(write..n, 0);
2552            energies.truncate(new_len);
2553            d.truncate(new_len);
2554        }
2555
2556        assert_eq!(energies, [1.0, 2.0, 3.0, 4.0]);
2557        // d[1]=21.0 (last of the 2.0 pair), d[2]=32.0 (last of the 3.0 triple)
2558        assert_eq!(d, [10.0, 21.0, 32.0, 40.0]);
2559    }
2560}