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}