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