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): parsed and SKIPPED.
126 //
127 // NEREIDS does not compute URR average cross sections — the
128 // Hauser-Feshbach path was removed (it lacked the ENDF
129 // width-fluctuation correction, a systematically wrong average).
130 // We still consume the URR body to keep the line stream aligned
131 // so resolved ranges in the same evaluation remain accessible,
132 // and tag the range Unresolved (non-evaluable → Skip in the
133 // physics layer). Structural guards inside the skip helpers
134 // detect malformed records that would otherwise misalign the
135 // cursor. Reference: ENDF-6 §2.2.2.
136 let nro_urr = range_cont.n1; // energy-dependent scattering radius flag
137 let naps_urr = range_cont.n2; // scattering radius calculation flag
138 // A TAB1 AP(E) record precedes the URR body when NRO != 0.
139 // Store it (converted to fm) on the placeholder, mirroring the
140 // LRF=7 skip: the placeholder preserves everything the header
141 // carries even though the range is never evaluated.
142 let ap_table_urr = if nro_urr != 0 {
143 let mut tab = parse_tab1(&lines, &mut pos)?;
144 for pt in &mut tab.points {
145 pt.1 *= ENDF_RADIUS_TO_FM;
146 }
147 Some(tab)
148 } else {
149 None
150 };
151
152 // LRF must be 1 or 2 for the URR (ENDF-6 §2.2.2). Any other
153 // value is a malformed URR record; reject it as an unsupported
154 // format — consistent with the sibling malformed-record guards
155 // (INT range, N1 relations) — rather than heuristically
156 // consuming a guessed body and silently dropping the span.
157 if lrf != 1 && lrf != 2 {
158 return Err(EndfParseError::UnsupportedFormat(format!(
159 "LRU=2 (URR) with LRF={lrf}: ENDF-6 §2.2.2 restricts \
160 the unresolved region to LRF=1 or LRF=2"
161 )));
162 }
163
164 // LFW=1/LRF=1 uses the "Case B" shared-energy-grid layout
165 // (ENDF-6 §2.2.2.1); LFW=1/LRF=2 is byte-identical to LFW=0/LRF=2.
166 let (spi_urr, ap_urr_fm) = if lfw != 0 && lrf == 1 {
167 skip_urr_lfw1_lrf1(&lines, &mut pos)?
168 } else {
169 skip_urr_range(&lines, &mut pos, lrf)?
170 };
171
172 all_ranges.push(ResonanceRange {
173 energy_low,
174 energy_high,
175 resolved: false,
176 formalism: ResonanceFormalism::Unresolved,
177 target_spin: spi_urr,
178 scattering_radius: ap_urr_fm,
179 naps: naps_urr,
180 ap_table: ap_table_urr,
181 l_groups: Vec::new(),
182 r_external: vec![],
183 });
184 continue;
185 }
186
187 if lru == 0 {
188 // LRU=0: scattering-radius-only range (no resonance parameters).
189 // ENDF-6 §2.1: after the range CONT (and optional TAB1 if
190 // NRO!=0), a single CONT record follows: [SPI, AP, 0, 0,
191 // NLS=0, 0]. We consume the NRO TAB1 if present, then the CONT,
192 // and store a non-evaluable placeholder (rather than dropping
193 // the range) so the LRU=0 span is named by skip_description and
194 // the no-evaluable-content error — a file whose only range is
195 // LRU=0 is still rejected (NEREIDS cannot evaluate a
196 // resonance-parameter-free stanza, and loading it would yield
197 // silent zero cross-sections), but with an accurate message.
198 let nro_lru0 = range_cont.n1;
199 let naps_lru0 = range_cont.n2;
200 let ap_table_lru0 = if nro_lru0 != 0 {
201 // TAB1 AP(E) precedes the SPI/AP CONT; consume and store it.
202 let mut tab = parse_tab1(&lines, &mut pos)?;
203 for pt in &mut tab.points {
204 pt.1 *= ENDF_RADIUS_TO_FM;
205 }
206 Some(tab)
207 } else {
208 None
209 };
210 // CONT: SPI, AP, 0, 0, NLS=0, 0
211 // Validate NLS=0 (#123): a non-zero NLS in an LRU=0 range is
212 // malformed and would cause the parser to look for L-groups that
213 // don't exist, misaligning the cursor for subsequent ranges.
214 let spi_cont = parse_cont(&lines, &mut pos)?;
215 // ENDF-6 §2.1: the SPI/AP CONT is [SPI, AP, 0, 0, NLS=0, 0].
216 // Validate that L1 and L2 are both zero — non-zero values
217 // indicate a malformed or mis-identified record.
218 if spi_cont.l1 != 0 {
219 return Err(EndfParseError::UnsupportedFormat(format!(
220 "LRU=0 range: L1={} in SPI/AP CONT record must be 0",
221 spi_cont.l1
222 )));
223 }
224 if spi_cont.l2 != 0 {
225 return Err(EndfParseError::UnsupportedFormat(format!(
226 "LRU=0 range: L2={} in SPI/AP CONT record must be 0",
227 spi_cont.l2
228 )));
229 }
230 if spi_cont.n1 != 0 {
231 return Err(EndfParseError::UnsupportedFormat(format!(
232 "LRU=0 range: NLS={} in SPI/AP CONT record must be 0 \
233 (scattering-radius-only ranges have no L-groups)",
234 spi_cont.n1
235 )));
236 }
237 if spi_cont.n2 != 0 {
238 return Err(EndfParseError::UnsupportedFormat(format!(
239 "LRU=0 range: N2={} in SPI/AP CONT record must be 0",
240 spi_cont.n2
241 )));
242 }
243 all_ranges.push(ResonanceRange {
244 energy_low,
245 energy_high,
246 resolved: false,
247 formalism: ResonanceFormalism::ScatteringRadiusOnly,
248 target_spin: spi_cont.c1,
249 scattering_radius: spi_cont.c2 * ENDF_RADIUS_TO_FM,
250 naps: naps_lru0,
251 ap_table: ap_table_lru0,
252 l_groups: Vec::new(),
253 r_external: vec![],
254 });
255 continue;
256 }
257
258 if lru != 1 {
259 return Err(EndfParseError::UnsupportedFormat(format!(
260 "LRU={} not supported (expected 0=scattering-radius-only, 1=resolved, or 2=unresolved)",
261 lru
262 )));
263 }
264
265 let nro = range_cont.n1; // energy-dependent scattering radius flag
266 let naps = range_cont.n2; // scattering radius calculation flag
267
268 // If NRO != 0, a TAB1 record immediately follows giving AP(E).
269 // Parse and store it; scattering_radius_at(E) will interpolate it
270 // at each energy point. Reference: ENDF-6 §2.2.1; SAMMY mlb/mmlb1.f90.
271 let ap_table = if nro != 0 {
272 let mut tab = parse_tab1(&lines, &mut pos)?;
273 // AP(E) y-values are in 10⁻¹² cm; convert to fm.
274 for pt in &mut tab.points {
275 pt.1 *= ENDF_RADIUS_TO_FM;
276 }
277 Some(tab)
278 } else {
279 None
280 };
281
282 // ENDF-6 Formats Manual: LRF values for resolved resonance region
283 // LRF=1: Single-Level Breit-Wigner (SLBW)
284 // LRF=2: Multi-Level Breit-Wigner (MLBW)
285 // LRF=3: Reich-Moore
286 // LRF=4: Adler-Adler (deprecated, not supported)
287 // LRF=7: R-Matrix Limited (general)
288 let formalism = match lrf {
289 1 => ResonanceFormalism::SLBW,
290 2 => ResonanceFormalism::MLBW,
291 3 => ResonanceFormalism::ReichMoore,
292 7 => ResonanceFormalism::RMatrixLimited,
293 _ => {
294 return Err(EndfParseError::UnsupportedFormat(format!(
295 "LRF={} not yet supported",
296 lrf
297 )));
298 }
299 };
300
301 let mut ctx = RangeParseContext {
302 lines: &lines,
303 pos: &mut pos,
304 energy_low,
305 energy_high,
306 naps,
307 ap_table,
308 };
309 let range = match formalism {
310 ResonanceFormalism::MLBW | ResonanceFormalism::SLBW => {
311 parse_bw_range(&mut ctx, formalism)?
312 }
313 ResonanceFormalism::ReichMoore => parse_reich_moore_range(&mut ctx)?,
314 ResonanceFormalism::RMatrixLimited => skip_rmatrix_limited_range(&mut ctx)?,
315 ResonanceFormalism::Unresolved => {
316 // Unreachable: Unresolved is only assigned in the LRU=2 branch above.
317 unreachable!("Unresolved formalism should not appear in LRU=1 dispatch");
318 }
319 ResonanceFormalism::ScatteringRadiusOnly => {
320 // Unreachable: ScatteringRadiusOnly is only assigned in the
321 // LRU=0 branch above.
322 unreachable!(
323 "ScatteringRadiusOnly formalism should not appear in LRU=1 dispatch"
324 );
325 }
326 };
327 all_ranges.push(range);
328 }
329 }
330
331 // Multi-MAT detection (#114, #123): since `lines` is pre-filtered to
332 // MF=2/MT=151, any unconsumed lines are definitively from another material.
333 // The previous character-based heuristic for distinguishing "real data" from
334 // SEND/FEND/MEND/TEND records was overly complex — those section-end records
335 // use different MF/MT codes and are already excluded by the filter above.
336 //
337 // Assumption: trailing whitespace-only lines that happen to pass the MF/MT
338 // filter (i.e. have " 2" at cols 70-72 and "151" at cols 72-75) would also
339 // trigger this check. In practice, ENDF files do not contain such lines —
340 // trailing blanks either lack the MF/MT fields entirely or use MF=0/MT=0,
341 // both of which are excluded by the filter in `parse_endf_file2`.
342 if pos < lines.len() {
343 return Err(EndfParseError::UnsupportedFormat(
344 "Multiple materials detected in MF=2/MT=151: unconsumed data lines \
345 remain after parsing the first material. Multi-MAT files are not \
346 supported; split the file into single-material ENDF files."
347 .to_string(),
348 ));
349 }
350
351 let data = ResonanceData {
352 isotope,
353 za,
354 awr,
355 ranges: all_ranges,
356 };
357
358 // Fail loudly when the evaluation carries NO evaluable content. A file
359 // whose every range is a parse-and-skip placeholder (LRF=7, LRU=2, or an
360 // LRU=0 scattering-radius-only stanza) would otherwise "load" with zero
361 // resonances, return zero cross-sections everywhere, and produce
362 // transmission ≡ 1 with no signal to the caller. Files with at least one
363 // evaluable range still load: parse-and-skip exists for real mixed tapes
364 // (e.g. Ta-181, U-238, Pu-240) whose resolved ranges remain fully usable.
365 if !data.has_evaluable_range() {
366 let detail = if data.ranges.is_empty() {
367 "the file carries no resonance-parameter ranges at all".to_string()
368 } else {
369 let skipped: Vec<String> = data.ranges.iter().map(|r| r.skip_description()).collect();
370 format!(
371 "every range in this file is a parse-and-skip placeholder: {}",
372 skipped.join("; ")
373 )
374 };
375 return Err(EndfParseError::UnsupportedFormat(format!(
376 "No evaluable resonance ranges: NEREIDS evaluates resolved LRF=1/2/3 \
377 (SLBW/MLBW/Reich-Moore) only, and {detail}. Cross-sections would be \
378 identically zero (transmission = 1) over the full energy grid."
379 )));
380 }
381
382 Ok(data)
383}
384
385/// Shared context for ENDF range parsers.
386///
387/// Groups the file-position state (`lines`, `pos`) with the fields from the
388/// range CONT record that every range parser needs, eliminating long argument
389/// lists.
390struct RangeParseContext<'a> {
391 lines: &'a [&'a str],
392 pos: &'a mut usize,
393 energy_low: f64,
394 energy_high: f64,
395 naps: i32,
396 ap_table: Option<Tab1>,
397}
398
399/// Parse a Breit-Wigner (SLBW or MLBW) resolved resonance range.
400///
401/// ENDF-6 File 2, LRF=1 (SLBW) / LRF=2 (MLBW):
402/// - CONT: SPI, AP, 0, 0, NLS, 0
403/// - For each L-value:
404/// - CONT: AWRI, 0.0, L, 0, 6*NRS, NRS
405/// - LIST: NRS resonances, each 6 values: ER, AJ, GT, GN, GG, GF
406///
407/// Reference: ENDF-6 Formats Manual Section 2.2.1.1
408fn parse_bw_range(
409 ctx: &mut RangeParseContext<'_>,
410 formalism: ResonanceFormalism,
411) -> Result<ResonanceRange, EndfParseError> {
412 // CONT: SPI, AP, 0, 0, NLS, 0
413 // ENDF AP is in 10⁻¹² cm; convert to fm (×10).
414 let cont = parse_cont(ctx.lines, ctx.pos)?;
415 let target_spin = cont.c1;
416 let scattering_radius = cont.c2 * ENDF_RADIUS_TO_FM;
417 let nls = checked_count(cont.n1, "NLS")?; // number of L-values
418
419 // Zero NLS means a resolved range with no L-groups: out of spec
420 // (ENDF-6 §2.2.1.1 requires NLS >= 1; NLS=0 belongs to LRU=0
421 // scattering-radius-only stanzas) and silently inert if accepted —
422 // the range would count as evaluable yet contribute zero cross-section
423 // everywhere. Mirrors the NJS=0 guards in the URR skip paths.
424 if nls == 0 {
425 return Err(EndfParseError::UnsupportedFormat(format!(
426 "{formalism:?} range: NLS=0 (ENDF-6 §2.2.1.1 requires at least one \
427 L-group in a resolved range; NLS=0 is reserved for LRU=0)"
428 )));
429 }
430
431 let mut l_groups = Vec::with_capacity(nls);
432
433 for _ in 0..nls {
434 // CONT: AWRI, QX, L, LRX, 6*NRS, NRS
435 let l_cont = parse_cont(ctx.lines, ctx.pos)?;
436 let awr_l = l_cont.c1;
437 let qx = l_cont.c2; // Q-value for competitive width (eV)
438 // Validate L is non-negative (#123): negative L1 wraps to a huge u32.
439 if l_cont.l1 < 0 {
440 return Err(EndfParseError::UnsupportedFormat(format!(
441 "BW range: negative L={}",
442 l_cont.l1
443 )));
444 }
445 let l_val = l_cont.l1 as u32;
446 let lrx = l_cont.l2; // competitive width flag
447 let n1 = checked_count(l_cont.n1, "N1")?; // should be 6*NRS
448 let nrs = checked_count(l_cont.n2, "NRS")?; // number of resonances
449
450 // Validate N1 == 6*NRS (#123): a mismatch means the record is malformed
451 // and reading N1 values would over-/under-consume lines.
452 if n1 != 6 * nrs {
453 return Err(EndfParseError::UnsupportedFormat(format!(
454 "BW range L={l_val}: N1={n1} != 6*NRS={} (NRS={nrs})",
455 6 * nrs
456 )));
457 }
458
459 let mut resonances = Vec::with_capacity(nrs);
460
461 // Each resonance is 6 values on one line (or spanning lines).
462 // In ENDF format, LIST records pack 6 values per line.
463 let total_values = nrs * 6;
464 let values = parse_list_values(ctx.lines, ctx.pos, total_values)?;
465
466 for i in 0..nrs {
467 let base = i * 6;
468 resonances.push(Resonance {
469 energy: values[base], // ER
470 j: values[base + 1], // AJ
471 gn: values[base + 3], // GN (neutron width)
472 gg: values[base + 4], // GG (gamma width)
473 gfa: values[base + 5], // GF (fission width)
474 gfb: 0.0, // Not used in BW
475 // Note: values[base+2] is GT (total width) — derived, not stored
476 });
477 }
478
479 l_groups.push(LGroup {
480 l: l_val,
481 awr: awr_l,
482 apl: 0.0, // Not in BW format
483 qx,
484 lrx,
485 resonances,
486 });
487 }
488
489 // A resolved range whose L-groups are all empty (every NRS=0) is accepted
490 // by reference readers (SAMMY, OpenScale), so it is NOT a hard error here.
491 // NEREIDS cannot evaluate it: its J-groups (and the hard-sphere
492 // potential-scattering term) derive from the resonance list, while SAMMY
493 // builds channels from quantum numbers and would retain hard-sphere
494 // scattering for the shape — a declared limitation. `is_evaluable()`
495 // therefore classifies it non-evaluable: a warn-and-skip placeholder like
496 // LRF=7/LRU=2/LRU=0, so a mixed file keeps its populated ranges, and a
497 // file where this is the only range fails the no-evaluable-content guard
498 // (fail-closed beats silently reporting zero as physics).
499
500 Ok(ResonanceRange {
501 energy_low: ctx.energy_low,
502 energy_high: ctx.energy_high,
503 resolved: true,
504 formalism,
505 target_spin,
506 scattering_radius,
507 naps: ctx.naps,
508 ap_table: ctx.ap_table.take(),
509 l_groups,
510 r_external: vec![],
511 })
512}
513
514/// Parse a Reich-Moore resolved resonance range.
515///
516/// ENDF-6 File 2, LRF=2:
517/// ENDF-6 File 2, LRF=3 (Reich-Moore):
518/// - CONT: SPI, AP, 0, 0, NLS, 0
519/// - For each L-value:
520/// - CONT: AWRI, APL, L, 0, 6*NRS, NRS
521/// - LIST: NRS resonances, each 6 values: ER, AJ, GN, GG, GFA, GFB
522///
523/// Reference: ENDF-6 Formats Manual Section 2.2.1.3
524/// Reference: SAMMY manual Section 2 (R-matrix theory)
525fn parse_reich_moore_range(
526 ctx: &mut RangeParseContext<'_>,
527) -> Result<ResonanceRange, EndfParseError> {
528 // CONT: SPI, AP, 0, 0, NLS, 0
529 // ENDF AP is in 10⁻¹² cm; convert to fm (×10).
530 let cont = parse_cont(ctx.lines, ctx.pos)?;
531 let target_spin = cont.c1;
532 let scattering_radius = cont.c2 * ENDF_RADIUS_TO_FM;
533 let nls = checked_count(cont.n1, "NLS")?; // number of L-values
534
535 // Zero NLS means a resolved range with no L-groups: out of spec
536 // (ENDF-6 §2.2.1.1 requires NLS >= 1; NLS=0 belongs to LRU=0
537 // scattering-radius-only stanzas) and silently inert if accepted —
538 // the range would count as evaluable yet contribute zero cross-section
539 // everywhere. Mirrors the NJS=0 guards in the URR skip paths.
540 if nls == 0 {
541 return Err(EndfParseError::UnsupportedFormat(
542 "Reich-Moore range: NLS=0 (ENDF-6 §2.2.1.1 requires at least one \
543 L-group in a resolved range; NLS=0 is reserved for LRU=0)"
544 .to_string(),
545 ));
546 }
547
548 let mut l_groups = Vec::with_capacity(nls);
549
550 for _ in 0..nls {
551 // CONT: AWRI, APL, L, 0, 6*NRS, NRS
552 let l_cont = parse_cont(ctx.lines, ctx.pos)?;
553 let awr_l = l_cont.c1;
554 let apl = l_cont.c2 * ENDF_RADIUS_TO_FM; // L-dependent scattering radius
555 // Validate L is non-negative (#123): negative L1 wraps to a huge u32.
556 if l_cont.l1 < 0 {
557 return Err(EndfParseError::UnsupportedFormat(format!(
558 "Reich-Moore range: negative L={}",
559 l_cont.l1
560 )));
561 }
562 let l_val = l_cont.l1 as u32;
563 let n1 = checked_count(l_cont.n1, "N1")?; // should be 6*NRS
564 let nrs = checked_count(l_cont.n2, "NRS")?; // number of resonances
565
566 // Validate N1 == 6*NRS (#123): a mismatch means the record is malformed
567 // and reading N1 values would over-/under-consume lines.
568 if n1 != 6 * nrs {
569 return Err(EndfParseError::UnsupportedFormat(format!(
570 "Reich-Moore range L={l_val}: N1={n1} != 6*NRS={} (NRS={nrs})",
571 6 * nrs
572 )));
573 }
574
575 let mut resonances = Vec::with_capacity(nrs);
576
577 // Each resonance is 6 values: ER, AJ, GN, GG, GFA, GFB
578 let total_values = nrs * 6;
579 let values = parse_list_values(ctx.lines, ctx.pos, total_values)?;
580
581 for i in 0..nrs {
582 let base = i * 6;
583 resonances.push(Resonance {
584 energy: values[base], // ER (eV)
585 j: values[base + 1], // AJ (total J)
586 gn: values[base + 2], // GN (neutron width, eV)
587 gg: values[base + 3], // GG (gamma width, eV)
588 gfa: values[base + 4], // GFA (fission width 1, eV)
589 gfb: values[base + 5], // GFB (fission width 2, eV)
590 });
591 }
592
593 l_groups.push(LGroup {
594 l: l_val,
595 awr: awr_l,
596 apl,
597 qx: 0.0, // Not used in Reich-Moore
598 lrx: 0, // Not used in Reich-Moore
599 resonances,
600 });
601 }
602
603 // A resolved range whose L-groups are all empty (every NRS=0) is accepted
604 // by reference readers (SAMMY, OpenScale), so it is NOT a hard error here.
605 // NEREIDS cannot evaluate it: its J-groups (and the hard-sphere
606 // potential-scattering term) derive from the resonance list, while SAMMY
607 // builds channels from quantum numbers and would retain hard-sphere
608 // scattering for the shape — a declared limitation. `is_evaluable()`
609 // therefore classifies it non-evaluable: a warn-and-skip placeholder like
610 // LRF=7/LRU=2/LRU=0, so a mixed file keeps its populated ranges, and a
611 // file where this is the only range fails the no-evaluable-content guard
612 // (fail-closed beats silently reporting zero as physics).
613
614 Ok(ResonanceRange {
615 energy_low: ctx.energy_low,
616 energy_high: ctx.energy_high,
617 resolved: true,
618 formalism: ResonanceFormalism::ReichMoore,
619 target_spin,
620 scattering_radius,
621 naps: ctx.naps,
622 ap_table: ctx.ap_table.take(),
623 l_groups,
624 r_external: vec![],
625 })
626}
627
628/// Consume (skip) an R-Matrix Limited (LRF=7) resolved resonance range,
629/// advancing the line cursor past the particle-pair, channel, and resonance
630/// LIST records so any following range/material stays aligned.
631///
632/// NEREIDS does not evaluate LRF=7 cross sections — the RML physics was removed
633/// (its closed-channel treatment was incomplete: the Coulomb/SHF=1 closed-channel
634/// shift was unimplemented, and the evaluator was never validated against SAMMY)
635/// — so the parameters are discarded and the range is tagged
636/// `ResonanceFormalism::RMatrixLimited` (non-evaluable → Skip).
637///
638/// Record advancement and every guard of the removed evaluator are preserved
639/// verbatim, for two distinct reasons. Structural guards (KRM — it sets the
640/// NCH+1 vs NCH+2 row stride — and the NCH, IPP-range, NRS/NX/NPL checks)
641/// reject records that would otherwise misalign the stream. The quantum-flag
642/// guards (IFG/KRL, the PNT/mass particle-pair guards, KBK/KPS) do NOT
643/// affect the byte layout; they are retained as deliberate strictness parity
644/// with the removed evaluator — a file carrying flags the evaluator never
645/// supported is rejected rather than silently consumed.
646/// Only the RmlData/ParticlePair/RmlChannel/RmlResonance/SpinGroup construction
647/// is gone.
648///
649/// Reference: ENDF-6 Formats Manual §2.2.1.6; SAMMY rml/mrml01.f
650fn skip_rmatrix_limited_range(
651 ctx: &mut RangeParseContext<'_>,
652) -> Result<ResonanceRange, EndfParseError> {
653 // CONT: [SPI, AP, IFG, KRM, NJS, KRL]
654 // ENDF AP is in 10⁻¹² cm; convert to fm (×10).
655 let cont = parse_cont(ctx.lines, ctx.pos)?;
656 let target_spin = cont.c1;
657 let scattering_radius = cont.c2 * ENDF_RADIUS_TO_FM;
658 // IFG (L1): GAM representation flag (ENDF-6 §2.2.1.6). IFG=0 means channel
659 // widths GAM are given in eV; IFG=1 means reduced-width amplitudes (√eV).
660 // Only IFG=0 is supported; SAMMY's ENDF reader makes the same distinction.
661 let ifg = cont.l1;
662 if ifg != 0 {
663 return Err(EndfParseError::UnsupportedFormat(format!(
664 "LRF=7 IFG={ifg} (reduced-width amplitudes) is not supported (only \
665 IFG=0, channel widths in eV)"
666 )));
667 }
668 let krm = cont.l2 as u32; // R-matrix type: 2=standard, 3=Reich-Moore approx
669 // KRM=0/1/4 are defined in the ENDF spec but not supported here.
670 if krm != 2 && krm != 3 {
671 return Err(EndfParseError::UnsupportedFormat(format!(
672 "LRF=7 KRM={krm} is not supported (only KRM=2 and KRM=3)"
673 )));
674 }
675 let njs = checked_count(cont.n1, "NJS")?; // number of spin groups
676 // KRL (N2): kinematics flag. KRL=0 (non-relativistic) is universal;
677 // KRL=1 (relativistic) is not supported. SAMMY always writes 0.
678 let krl = cont.n2;
679 if krl != 0 {
680 return Err(EndfParseError::UnsupportedFormat(format!(
681 "LRF=7 KRL={krl} (relativistic kinematics) is not supported (only KRL=0)"
682 )));
683 }
684
685 // LIST: [0, 0, NPP, 0, 12*NPP, NPP] — particle pair definitions.
686 // NPP is authoritative in L1. Reference: ENDF-6 §2.2.1.6 Table 2.1.
687 let pp_cont = parse_cont(ctx.lines, ctx.pos)?;
688 let npp = checked_count(pp_cont.l1, "NPP")?;
689 let pp_values = parse_list_values(ctx.lines, ctx.pos, npp * 12)?;
690
691 // Validate-and-narrow an ENDF integer-coded particle-pair flag (PNT/SHF).
692 // A fractional or non-finite f64 is a malformed record and must not be
693 // silently truncated/saturated (PNT=1.7→1, PNT=NaN→0 would bypass the {0,1}
694 // check below).
695 fn pp_int_flag(value: f64, field: &str, idx: usize) -> Result<i32, EndfParseError> {
696 if !value.is_finite() || value.fract() != 0.0 {
697 return Err(EndfParseError::UnsupportedFormat(format!(
698 "LRF=7 particle pair {idx}: {field}={value} is not a finite integer"
699 )));
700 }
701 Ok(value as i32)
702 }
703
704 // Particle-pair validation. Values are discarded (the pair defs are not
705 // stored), but every quantum-flag guard is applied so malformed records are
706 // rejected exactly as before.
707 for i in 0..npp {
708 let b = i * 12;
709 let ma = pp_values[b];
710 let mb = pp_values[b + 1];
711 let pnt = pp_int_flag(pp_values[b + 7], "PNT", i)?;
712 // SHF is validated-and-narrowed (must be 0 or 1) but otherwise unused
713 // in the skip path — see the Coulomb note below.
714 pp_int_flag(pp_values[b + 8], "SHF", i)?;
715 // PNT (Lpent) must be 0 or 1 (SAMMY rml/mrml03.f:22 Check_Quantum rejects
716 // Lpent ∉ {0,1}; PNT=2 "ASSIGN" is unimplemented).
717 if pnt != 0 && pnt != 1 {
718 return Err(EndfParseError::UnsupportedFormat(format!(
719 "LRF=7 particle pair {i}: PNT={pnt} is not supported (only PNT=0 \
720 and PNT=1; SAMMY rejects Lpent outside {{0,1}})"
721 )));
722 }
723 // A massless pair (photon/eliminated channel, MA=0) must carry PNT=0.
724 if ma < 0.5 && pnt != 0 {
725 return Err(EndfParseError::UnsupportedFormat(format!(
726 "LRF=7 particle pair {i}: massless pair (MA={ma}) with PNT={pnt} is \
727 invalid; a photon/eliminated channel must have PNT=0"
728 )));
729 }
730 // PNT=1 requires finite positive masses yielding a finite reduced mass
731 // μ = MA·MB/(MA+MB) (computed exactly as the physics did); catches
732 // overflow of MA·MB to ∞ and the MA+MB=0 / sign cases.
733 if pnt == 1 {
734 let reduced_mass = ma * mb / (ma + mb);
735 if !(ma.is_finite()
736 && mb.is_finite()
737 && ma > 0.0
738 && mb > 0.0
739 && reduced_mass.is_finite()
740 && reduced_mass > 0.0)
741 {
742 return Err(EndfParseError::UnsupportedFormat(format!(
743 "LRF=7 particle pair {i}: PNT=1 requires finite positive masses \
744 yielding a finite reduced mass (MA={ma}, MB={mb})"
745 )));
746 }
747 }
748 // Coulomb + SHF=1 is valid ENDF and SAMMY accepts it (mrml03.f rejects
749 // only SHF outside {0,1}; mrml07.f passes Ishift into the open-channel
750 // Coulomb calculation). The removed evaluator could not compute the
751 // closed-channel Coulomb shift and rejected the combination; with the
752 // parameters now discarded there is nothing to protect, and rejecting
753 // would fail a whole mixed file over a range that is skipped anyway.
754 // SHF is still validated-and-narrowed above (pp_int_flag), so a
755 // malformed flag remains a hard error.
756 }
757
758 for _ in 0..njs {
759 // LIST: [AJ, PJ, KBK, KPS, 6*(NCH+1), NCH+1]
760 let sg_cont = parse_cont(ctx.lines, ctx.pos)?;
761 let kbk = sg_cont.l1; // background R-matrix flag
762 let kps = sg_cont.l2; // phase shift flag
763
764 // KBK: background R-matrix correction (R-external function on a
765 // subset of channels). Per the printed ENDF-6 §2.2.1.6 Tables 2.4/2.5
766 // KBK is described as a nonzero flag with NCH background records,
767 // while the reference reader OpenScale
768 // (external/openScale/repo/packages/ScaleUtils/EndfLib/endf/File2.cpp:444-524)
769 // treats KBK as a sparse record count, with each subrecord's L1 holding
770 // the 1-based channel index and L2 holding the LBK formalism flag
771 // (LBK ∈ {0=no payload, 1=two TAB1, 2=LIST(5), 3=LIST(3)}). The two
772 // conventions disagree on (a) the loop bound, (b) the per-subrecord
773 // control-field positions, and (c) the payload shape per LBK value.
774 //
775 // No ENDF/B-VIII.0 evaluation in the local cache has nonzero KBK or
776 // KPS to disambiguate, and the only nonzero example located on disk
777 // is OpenScale's synthetic F-19 R-external test fixture
778 // (Ampx/TestRunner/test/data/polident/f19_rext.endf), which follows
779 // the OpenScale convention. NEREIDS's previous layout matched neither
780 // convention. Until a policy decision is made (strict-manual vs.
781 // OpenScale-compat) and a real ENDF/B-VIII.0 evaluation with R-external
782 // is available to validate against, reject nonzero KBK explicitly so
783 // the parser cannot silently misalign the stream past this spin group.
784 //
785 // The reject runs immediately after reading the spin-group CONT —
786 // before parsing the (potentially large) channel and resonance LISTs —
787 // so that unsupported files fail fast without wasting allocation and
788 // parsing work on records that will be discarded.
789 if kbk != 0 {
790 let nch_plus_one_raw = sg_cont.n2;
791 return Err(EndfParseError::UnsupportedFormat(format!(
792 "LRF=7 KBK={kbk} != 0 (R-external background) for spin group with \
793 NCH+1={nch_plus_one_raw}: \
794 the ENDF-6 manual vs. OpenScale layout dispute is unresolved and NEREIDS \
795 does not yet parse nonzero KBK. Use the SAMMY .par/.inp converter \
796 (sammy_to_resonance_data_multi) if R-external is required."
797 )));
798 }
799
800 // KPS: tabulated penetrability/phase-shift override per channel.
801 // Same documentation-vs-implementation dispute as KBK above
802 // (OpenScale File2.cpp:439-441 throws "kps > 0 for lrf=7 not yet
803 // supported" and never reads the subrecords). NEREIDS rejects nonzero
804 // KPS for the same reason: no validated reference layout, no real
805 // evaluation to test against.
806 if kps != 0 {
807 let nch_plus_one_raw = sg_cont.n2;
808 return Err(EndfParseError::UnsupportedFormat(format!(
809 "LRF=7 KPS={kps} != 0 (tabulated penetrability/phase-shift override) \
810 for spin group with NCH+1={nch_plus_one_raw}: \
811 NEREIDS does not yet parse nonzero KPS. \
812 OpenScale itself rejects this case (\"kps > 0 for lrf=7 not yet supported\")."
813 )));
814 }
815
816 let npl = checked_count(sg_cont.n1, "NPL")?; // 6*(NCH+1)
817 let nch_plus_one = checked_count(sg_cont.n2, "NCH+1")?; // NCH+1
818
819 // NCH+1 <= 1 would imply zero physical channels (NCH = 0), which is
820 // meaningless for a resonance range — every spin group must have at
821 // least one channel. Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml01.f.
822 if nch_plus_one <= 1 {
823 return Err(EndfParseError::UnsupportedFormat(format!(
824 "RML spin-group LIST: NCH+1 must be >= 2, got NCH+1={nch_plus_one}"
825 )));
826 }
827 let nch = nch_plus_one - 1;
828
829 let sg_values = parse_list_values(ctx.lines, ctx.pos, npl)?;
830
831 // The LIST must carry at least 6*(NCH+1) values: the first row is the
832 // eliminated capture (gamma) channel — SAMMY reads all NCH+1 rows as
833 // channels, the first being the eliminated one — followed by the NCH
834 // particle channels.
835 // Reference: ENDF-6 §2.2.1.6 Table 2.3; SAMMY rml/mrml01.f lines
836 // 104-107, 381; ndf/WriteRrEndf.cpp:1033.
837 let expected_npl = 6 * (nch + 1);
838 if npl < expected_npl {
839 return Err(EndfParseError::UnsupportedFormat(format!(
840 "LRF=7 spin-group LIST: NPL={npl} < 6*(NCH+1)={expected_npl}"
841 )));
842 }
843
844 // Validate each particle channel's IPP (1-based particle-pair index)
845 // is in range; the channel definitions [IPP, L, SCH, BND, APE, APT]
846 // are otherwise discarded (LRF=7 is not evaluated). The first 6-value
847 // row is the eliminated capture channel, which carries no particle
848 // pair and is exempt from the IPP check.
849 // Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml01.f (Ippx test).
850 for c in 0..nch {
851 let b = 6 + c * 6; // skip the eliminated-capture-channel row
852 let ipp_raw = sg_values[b] as usize;
853 if ipp_raw == 0 || ipp_raw > npp {
854 return Err(EndfParseError::UnsupportedFormat(format!(
855 "LRF=7 spin-group channel IPP={ipp_raw} is out of range 1..={npp}"
856 )));
857 }
858 }
859
860 // LIST: [0, 0, 0, NRS, 6*NX, NX] — resonance parameters.
861 //
862 // ENDF-6 §2.2.1.6 fixes the control fields as [0, 0, 0, L2=NRS, N1=6*NX,
863 // N2=NX]: NRS (resonance count) is in L2; NX (packed 6-float row count =
864 // NRS·ceil(stride/6), stride NCH+1 for KRM=2 / NCH+2 for KRM=3) is in N2;
865 // N1 = 6*NX. For wide spin groups (e.g. F-19, NCH≥5) NX > NRS, so NRS
866 // MUST be read from L2 and NX from N2 — mixing them mis-sizes the block.
867 // SAMMY mrml01.f:413-415; OpenScale File2.cpp:415,686-697.
868 let res_cont = parse_cont(ctx.lines, ctx.pos)?;
869 let nrs = checked_count(res_cont.l2, "NRS")?;
870 let nx = checked_count(res_cont.n2, "NX")?;
871 let res_npl = checked_count(res_cont.n1, "NPL")?;
872 if res_npl != 6 * nx {
873 return Err(EndfParseError::UnsupportedFormat(format!(
874 "LRF=7 resonance LIST: N1 ({res_npl}) != 6 * N2 ({}); ENDF-6 §2.2.1.6 \
875 requires NPL = 6*NX for the packed-row layout",
876 6 * nx
877 )));
878 }
879 // The per-resonance row count is constant within a spin group, so NX is
880 // always an integer multiple of NRS; a non-multiple would yield a
881 // fractional stride and mis-align resonance reads.
882 if nrs > 0 && nx % nrs != 0 {
883 return Err(EndfParseError::UnsupportedFormat(format!(
884 "LRF=7 resonance LIST: N2/NX ({nx}) is not a multiple of L2/NRS ({nrs}); \
885 ENDF-6 §2.2.1.6 requires NX = NRS * ceil(stride/6) where stride is \
886 NCH+1 for KRM=2 and NCH+2 for KRM=3"
887 )));
888 }
889 // Canonical empty spin group (ENDF-6 §2.2.1.6 + OpenScale File2.cpp:
890 // 683-697): NRS=0 carries a single zero-filler row, so NX must be 1.
891 if nrs == 0 && nx != 1 {
892 return Err(EndfParseError::UnsupportedFormat(format!(
893 "LRF=7 resonance LIST: NRS=0 requires NX=1 (single zero-filler row \
894 per ENDF-6 §2.2.1.6 + OpenScale File2.cpp:683-697); got NX={nx}"
895 )));
896 }
897 // Consume the resonance block, then validate the KRM-dependent stride so
898 // a malformed row layout is rejected rather than silently accepted.
899 // KRM=2 per-resonance row = [ER, γ_1..γ_NCH, pad] → stride ≥ NCH+1;
900 // KRM=3 = [ER, Γγ, Γ_1..Γ_NCH, pad] → stride ≥ NCH+2.
901 // Reference: ENDF-6 §2.2.1.6; SAMMY rml/mrml01.f ENDF123.
902 parse_list_values(ctx.lines, ctx.pos, res_npl)?;
903 if nrs > 0 {
904 let min_stride = if krm == 3 { nch + 2 } else { nch + 1 };
905 if res_npl % nrs != 0 {
906 return Err(EndfParseError::UnsupportedFormat(format!(
907 "LRF=7 resonance block NPL={res_npl} is not divisible by NRS={nrs}"
908 )));
909 }
910 let s = res_npl / nrs;
911 if s < min_stride {
912 return Err(EndfParseError::UnsupportedFormat(format!(
913 "LRF=7 resonance stride={s} < {}={min_stride} \
914 (KRM={krm}, NPL={res_npl}, NRS={nrs})",
915 if krm == 3 { "NCH+2" } else { "NCH+1" }
916 )));
917 }
918 }
919 }
920
921 Ok(ResonanceRange {
922 energy_low: ctx.energy_low,
923 energy_high: ctx.energy_high,
924 resolved: true,
925 formalism: ResonanceFormalism::RMatrixLimited,
926 target_spin,
927 scattering_radius,
928 naps: ctx.naps,
929 ap_table: ctx.ap_table.take(),
930 l_groups: Vec::new(),
931 r_external: vec![],
932 })
933}
934
935/// Maximum sane ENDF count value.
936///
937/// ENDF files in practice never contain more than ~100k resonances per section.
938/// Accepting `i32::MAX` would cause enormous allocations (gigabytes) on
939/// malformed files. This cap is generous enough for any real evaluation while
940/// protecting against allocation bombs.
941const MAX_ENDF_COUNT: i32 = 1_000_000;
942
943/// Validate that an ENDF integer count is non-negative and return as `usize`.
944///
945/// Malformed records can contain negative counts which, if cast directly to
946/// `usize`, wrap to huge values and cause OOM panics in `Vec::with_capacity`
947/// or `parse_list_values`.
948fn checked_count(value: i32, label: &str) -> Result<usize, EndfParseError> {
949 if value < 0 {
950 return Err(EndfParseError::UnsupportedFormat(format!(
951 "Negative ENDF count: {label}={value}"
952 )));
953 }
954 if value > MAX_ENDF_COUNT {
955 return Err(EndfParseError::UnsupportedFormat(format!(
956 "ENDF count too large: {label}={value} (maximum {MAX_ENDF_COUNT})"
957 )));
958 }
959 Ok(value as usize)
960}
961
962// ---------------------------------------------------------------------------
963// Low-level ENDF line parsing helpers
964// ---------------------------------------------------------------------------
965
966/// Parsed CONT (control) record with 2 floats, 4 integers.
967struct ContRecord {
968 c1: f64,
969 c2: f64,
970 l1: i32,
971 l2: i32,
972 n1: i32,
973 n2: i32,
974}
975
976/// Parse a CONT record from the current line.
977fn parse_cont(lines: &[&str], pos: &mut usize) -> Result<ContRecord, EndfParseError> {
978 if *pos >= lines.len() {
979 return Err(EndfParseError::UnexpectedEof(
980 "Expected CONT record but reached end of data".to_string(),
981 ));
982 }
983 let line = lines[*pos];
984 *pos += 1;
985
986 Ok(ContRecord {
987 c1: parse_endf_float(line, 0)?,
988 c2: parse_endf_float(line, 1)?,
989 l1: parse_endf_int(line, 2)?,
990 l2: parse_endf_int(line, 3)?,
991 n1: parse_endf_int(line, 4)?,
992 n2: parse_endf_int(line, 5)?,
993 })
994}
995
996/// Parse a LIST of floating-point values spanning multiple lines.
997///
998/// ENDF packs 6 values per line. We read ceil(n/6) lines.
999fn parse_list_values(
1000 lines: &[&str],
1001 pos: &mut usize,
1002 n_values: usize,
1003) -> Result<Vec<f64>, EndfParseError> {
1004 let mut values = Vec::with_capacity(n_values);
1005 let n_lines = n_values.div_ceil(6);
1006
1007 for _ in 0..n_lines {
1008 if *pos >= lines.len() {
1009 return Err(EndfParseError::UnexpectedEof(
1010 "Expected LIST data but reached end".to_string(),
1011 ));
1012 }
1013 let line = lines[*pos];
1014 *pos += 1;
1015
1016 let remaining = n_values - values.len();
1017 let fields_on_line = remaining.min(6);
1018
1019 for field in 0..fields_on_line {
1020 values.push(parse_endf_float(line, field)?);
1021 }
1022 }
1023
1024 Ok(values)
1025}
1026
1027/// Parse a floating-point value from an 11-character ENDF field.
1028///
1029/// ENDF uses Fortran-style floats that may omit 'E', e.g.:
1030/// - " 1.234567+2" means 1.234567e+2
1031/// - "-3.456789-1" means -3.456789e-1
1032/// - " 0.000000+0" means 0.0
1033fn parse_endf_float(line: &str, field_index: usize) -> Result<f64, EndfParseError> {
1034 let start = field_index * 11;
1035 let end = start + 11;
1036
1037 if line.len() < end {
1038 // Short line — treat as zero.
1039 return Ok(0.0);
1040 }
1041
1042 let field = &line[start..end];
1043 let trimmed = field.trim();
1044
1045 if trimmed.is_empty() {
1046 return Ok(0.0);
1047 }
1048
1049 // Try standard Rust float parsing first.
1050 if let Ok(v) = trimmed.parse::<f64>() {
1051 return Ok(v);
1052 }
1053
1054 // Handle Fortran-style: "1.234567+2" or "-3.456789-1"
1055 // Look for +/- that is NOT the first character and NOT preceded by 'e'/'E'/'d'/'D'.
1056 let bytes = trimmed.as_bytes();
1057 for i in 1..bytes.len() {
1058 if (bytes[i] == b'+' || bytes[i] == b'-')
1059 && bytes[i - 1] != b'e'
1060 && bytes[i - 1] != b'E'
1061 && bytes[i - 1] != b'd'
1062 && bytes[i - 1] != b'D'
1063 && bytes[i - 1] != b'+'
1064 && bytes[i - 1] != b'-'
1065 {
1066 let mantissa = &trimmed[..i];
1067 let exp_slice = &trimmed[i..];
1068 // Strip spaces from the exponent only when present (some ENDF files
1069 // write "+ 4" not "+4"). Avoid allocation on the common path.
1070 let with_e = if exp_slice.contains(' ') {
1071 let exponent: String = exp_slice.chars().filter(|c| !c.is_whitespace()).collect();
1072 format!("{}E{}", mantissa, exponent)
1073 } else {
1074 format!("{}E{}", mantissa, exp_slice)
1075 };
1076 if let Ok(v) = with_e.parse::<f64>() {
1077 return Ok(v);
1078 }
1079 }
1080 }
1081
1082 Err(EndfParseError::InvalidNumber(format!(
1083 "Cannot parse ENDF float: '{}'",
1084 field
1085 )))
1086}
1087
1088/// Parse an integer from an 11-character ENDF field.
1089fn parse_endf_int(line: &str, field_index: usize) -> Result<i32, EndfParseError> {
1090 let start = field_index * 11;
1091 let end = start + 11;
1092
1093 if line.len() < end {
1094 return Ok(0);
1095 }
1096
1097 let field = &line[start..end];
1098 let trimmed = field.trim();
1099
1100 if trimmed.is_empty() {
1101 return Ok(0);
1102 }
1103
1104 // ENDF integers may have a decimal point (e.g., "1.000000+0" for 1).
1105 // Try integer parse first, then float-with-integral-value.
1106 if let Ok(v) = trimmed.parse::<i32>() {
1107 return Ok(v);
1108 }
1109
1110 // Try parsing as float, but reject non-integral values (e.g., "1.9e+0")
1111 // rather than silently truncating. Use the same ε=1e-6 tolerance as
1112 // `parse_tab1`'s NBT/INT validation so that integer fields stored as
1113 // ENDF floats ("1.000000+0") still round-trip, but a malformed
1114 // "1.900000+0" is surfaced as an InvalidNumber rather than parsed as 1.
1115 if let Ok(v) = parse_endf_float(line, field_index) {
1116 if (v - v.round()).abs() > 1e-6 {
1117 return Err(EndfParseError::InvalidNumber(format!(
1118 "Non-integral value in ENDF int field: '{}' (={})",
1119 field, v
1120 )));
1121 }
1122 return Ok(v.round() as i32);
1123 }
1124
1125 Err(EndfParseError::InvalidNumber(format!(
1126 "Cannot parse ENDF int: '{}'",
1127 field
1128 )))
1129}
1130
1131/// Consume (skip) a URR LFW=1/LRF=1 "Case B" range body (ENDF-6 §2.2.2.1),
1132/// advancing the line cursor past the shared fission-width energy LIST and each
1133/// per-(L,J) LIST record. NEREIDS does not evaluate URR cross sections, so the
1134/// parameter values are discarded; only the record structure is read, which is
1135/// what keeps the line stream aligned for any following range or SEND record.
1136/// Returns the header's `(SPI, AP)` — AP converted to fm — so the caller can
1137/// preserve them on the placeholder range.
1138///
1139/// The per-J control line (carrying `N1 = NE+6`) MUST be consumed before its
1140/// body — omitting it misaligns the stream by one record per J-group. The
1141/// `N1 = NE+6` guard is preserved to catch malformed records.
1142///
1143/// Reference: ENDF-6 §2.2.2.1 Case B; OpenScale `File2.cpp`
1144/// (`lfw==1 && lrf==1` branch) and AMPX `File2Unres.f90` — both OpenScale/AMPX
1145/// readers vendored under SAMMY's external tree, not SAMMY proper.
1146fn skip_urr_lfw1_lrf1(lines: &[&str], pos: &mut usize) -> Result<(f64, f64), EndfParseError> {
1147 // CONT: SPI, AP, LSSF, 0, NE, NLS
1148 let header = parse_cont(lines, pos)?;
1149 let spi = header.c1;
1150 let ap_fm = header.c2 * ENDF_RADIUS_TO_FM;
1151 let ne = checked_count(header.n1, "NE")?;
1152 let nls = checked_count(header.n2, "NLS")?;
1153
1154 // LIST: NE shared fission-width-grid energies.
1155 parse_list_values(lines, pos, ne)?;
1156
1157 for _ in 0..nls {
1158 // CONT: AWRI, 0, L, 0, NJS, 0
1159 let l_cont = parse_cont(lines, pos)?;
1160 let njs = checked_count(l_cont.n1, "NJS")?;
1161
1162 // Zero NJS means no J-groups for this L-value, which is malformed
1163 // (ENDF §2.2.2.1 requires at least one J-group per L-group) and would
1164 // under-consume the body, surfacing as a confusing downstream error.
1165 // Mirrors the NJS=0 guards in `skip_urr_range`.
1166 if njs == 0 {
1167 return Err(EndfParseError::UnsupportedFormat(format!(
1168 "URR LFW=1/LRF=1 L={}: NJS=0 (at least one J-group required)",
1169 l_cont.l1
1170 )));
1171 }
1172
1173 for _ in 0..njs {
1174 // Per-J LIST control: 0.0, 0.0, L, MUF, NE+6, 0. Consuming this
1175 // control record keeps the line stream aligned — the body follows.
1176 let j_cont = parse_cont(lines, pos)?;
1177 let n1 = checked_count(j_cont.n1, "N1")?;
1178 // SCALE validates this exact relation (File2.cpp: N1-6 == NE).
1179 if n1 != ne + 6 {
1180 return Err(EndfParseError::UnsupportedFormat(format!(
1181 "URR LFW=1/LRF=1: per-J N1={n1} ≠ NE+6={} (NE={ne})",
1182 ne + 6
1183 )));
1184 }
1185 // LIST body: [D, AJ, AMUN, GNO, GG, 0] + NE fission widths (discarded).
1186 parse_list_values(lines, pos, n1)?;
1187 }
1188 }
1189
1190 Ok((spi, ap_fm))
1191}
1192
1193/// Consume (skip) a URR (LRU=2) range body of the given LRF, advancing the line
1194/// cursor past every L- and (L,J)-record. NEREIDS does not evaluate URR cross
1195/// sections, so the parameter values are discarded; only the record structure
1196/// is read, which keeps the line stream aligned for any following range or SEND
1197/// record. Returns the header's `(SPI, AP)` — AP converted to fm — so the
1198/// caller can preserve them on the placeholder range.
1199///
1200/// Handles LFW=0/LRF=1 (energy-independent widths) and LRF=2 (tabulated
1201/// widths); LFW=1/LRF=2 is byte-identical to LFW=0/LRF=2 and routes here too.
1202/// LFW=1/LRF=1 (shared-grid "Case B") is handled by `skip_urr_lfw1_lrf1`.
1203///
1204/// The structural guards (N1=6*NJS for LRF=1; INT∈1..=5 and N1=6*(NE+1) for
1205/// LRF=2; NJS>0) are preserved: they catch malformed records that would
1206/// otherwise over-/under-consume lines and misalign the cursor.
1207///
1208/// Reference: ENDF-6 Formats Manual §2.2.2
1209fn skip_urr_range(lines: &[&str], pos: &mut usize, lrf: i32) -> Result<(f64, f64), EndfParseError> {
1210 // CONT: SPI, AP, 0, 0, NLS, 0
1211 let spi_cont = parse_cont(lines, pos)?;
1212 let spi = spi_cont.c1;
1213 let ap_fm = spi_cont.c2 * ENDF_RADIUS_TO_FM;
1214 let nls = checked_count(spi_cont.n1, "NLS")?;
1215
1216 if lrf == 1 {
1217 // LRF=1: energy-independent widths, one LIST block per L covering all J.
1218 for _ in 0..nls {
1219 // CONT: AWRI, 0, L, 0, N1=6*NJS, N2=NJS
1220 let l_cont = parse_cont(lines, pos)?;
1221 if l_cont.l1 < 0 {
1222 return Err(EndfParseError::UnsupportedFormat(format!(
1223 "URR LRF=1: negative L={}",
1224 l_cont.l1
1225 )));
1226 }
1227 let l = l_cont.l1 as u32;
1228 let n1 = checked_count(l_cont.n1, "N1")?; // 6*NJS
1229 let njs = checked_count(l_cont.n2, "NJS")?;
1230 if njs == 0 || n1 != 6 * njs {
1231 return Err(EndfParseError::UnsupportedFormat(format!(
1232 "URR LRF=1 L={l}: N1={n1} ≠ 6×NJS={} (NJS={njs})",
1233 6 * njs
1234 )));
1235 }
1236 // LIST body: [D, AJ, AMUN, GNO, GG, GF] × NJS (discarded).
1237 parse_list_values(lines, pos, n1)?;
1238 }
1239 } else {
1240 // LRF=2: energy-dependent width tables, one LIST per (L, J).
1241 for _ in 0..nls {
1242 // CONT: AWRI, 0, L, 0, NJS, 0
1243 let l_cont = parse_cont(lines, pos)?;
1244 if l_cont.l1 < 0 {
1245 return Err(EndfParseError::UnsupportedFormat(format!(
1246 "URR LRF=2: negative L={}",
1247 l_cont.l1
1248 )));
1249 }
1250 let l = l_cont.l1 as u32;
1251 let njs = checked_count(l_cont.n1, "NJS")?; // N1 = NJS for LRF=2
1252
1253 // Zero NJS means no J-groups for this L-value, which is malformed
1254 // (ENDF §2.2.2.2 requires at least one J-group per L-group).
1255 if njs == 0 {
1256 return Err(EndfParseError::UnsupportedFormat(format!(
1257 "URR LRF=2 L={l}: NJS=0 (at least one J-group required)"
1258 )));
1259 }
1260
1261 for _ in 0..njs {
1262 // CONT: AJ, 0, INT, 0, N1=6*(NE+1), N2=NE
1263 let j_cont = parse_cont(lines, pos)?;
1264 let int_code = j_cont.l1; // interpolation law (L1 field)
1265 // ENDF-6 §0.5 defines INT codes 1..=5; anything outside that
1266 // range — including negative values and INT=0 or INT≥6 — is a
1267 // malformed record.
1268 if !(1..=5).contains(&int_code) {
1269 return Err(EndfParseError::UnsupportedFormat(format!(
1270 "URR LRF=2: INT={int_code} out of spec (expected 1..=5)"
1271 )));
1272 }
1273 let n1 = checked_count(j_cont.n1, "N1")?; // 6*(NE+1)
1274 let ne = checked_count(j_cont.n2, "NE")?; // NE (number of energy points)
1275
1276 // Validate N1 = 6*(NE+1) before consuming the LIST body so a
1277 // malformed record cannot over-/under-consume lines.
1278 let expected_n1 = 6 * (ne + 1);
1279 if n1 != expected_n1 {
1280 return Err(EndfParseError::UnsupportedFormat(format!(
1281 "URR LRF=2: N1={n1} ≠ 6*(NE+1)={expected_n1} (NE={ne})"
1282 )));
1283 }
1284 // LIST body: DOF row + NE width rows (discarded).
1285 parse_list_values(lines, pos, n1)?;
1286 }
1287 }
1288 }
1289
1290 Ok((spi, ap_fm))
1291}
1292
1293/// Parse a TAB1 record into a `Tab1` interpolation table.
1294///
1295/// ENDF TAB1 layout (Reference: ENDF-6 Formats Manual §0.5):
1296/// ```text
1297/// CONT: [C1, C2, L1, L2, NR, NP]
1298/// NR×2 integer values: (NBT_i, INT_i) pairs — 6 per line
1299/// NP×2 float values: (x_i, y_i) pairs — 6 per line
1300/// ```
1301///
1302/// INT codes: 1=histogram, 2=lin-lin, 3=log-x/lin-y, 4=lin-x/log-y, 5=log-log.
1303fn parse_tab1(lines: &[&str], pos: &mut usize) -> Result<Tab1, EndfParseError> {
1304 let cont = parse_cont(lines, pos)?;
1305 let nr = checked_count(cont.n1, "NR")?; // number of interpolation regions
1306 let np = checked_count(cont.n2, "NP")?; // number of data points
1307
1308 // NR=0 is valid ENDF: it means a single implicit interpolation region
1309 // covering all NP points with no explicit boundary record. The
1310 // evaluate() call will fall through to the `unwrap_or(2)` default in
1311 // interp_code_for_interval(), which correctly returns INT=2 (lin-lin).
1312 // When NR=0, the loop below is a no-op and the interp_raw vec stays empty.
1313
1314 // Read NR×2 integers: (NBT, INT) pairs packed as ENDF floats.
1315 // Validate that values are integers, INT codes are in 1..=5, boundaries
1316 // are strictly increasing, and the last boundary equals NP.
1317 let interp_raw = parse_list_values(lines, pos, nr * 2)?;
1318 let mut boundaries = Vec::with_capacity(nr);
1319 let mut interp_codes = Vec::with_capacity(nr);
1320 for i in 0..nr {
1321 let nbt_raw = interp_raw[i * 2];
1322 let int_raw = interp_raw[i * 2 + 1];
1323
1324 // ENDF stores integers as floats (e.g. "2.000000+0"). They must be
1325 // exact whole numbers. Use a small epsilon (1e-6) rather than the
1326 // half-unit tolerance 0.5, which would silently accept 1.4 or 2.49.
1327 // NBT is a 1-based index (ENDF §0.5), so 0 is invalid.
1328 if (nbt_raw - nbt_raw.round()).abs() > 1e-6 || nbt_raw < 1.0 {
1329 return Err(EndfParseError::UnsupportedFormat(format!(
1330 "TAB1 NBT[{}] is not a positive integer: {}",
1331 i, nbt_raw
1332 )));
1333 }
1334 if (int_raw - int_raw.round()).abs() > 1e-6 {
1335 return Err(EndfParseError::UnsupportedFormat(format!(
1336 "TAB1 INT[{}] is not an integer: {}",
1337 i, int_raw
1338 )));
1339 }
1340 let int_code = int_raw.round() as u32;
1341 if !(1..=5).contains(&int_code) {
1342 return Err(EndfParseError::UnsupportedFormat(format!(
1343 "TAB1 INT[{}]={} is out of range 1..=5",
1344 i, int_code
1345 )));
1346 }
1347 let nbt = nbt_raw.round() as usize;
1348
1349 // Boundaries must be strictly increasing (ENDF §0.5).
1350 if let Some(&prev) = boundaries.last()
1351 && nbt <= prev
1352 {
1353 return Err(EndfParseError::UnsupportedFormat(format!(
1354 "TAB1 NBT[{}]={} is not greater than NBT[{}]={}",
1355 i,
1356 nbt,
1357 i - 1,
1358 prev
1359 )));
1360 }
1361 boundaries.push(nbt);
1362 interp_codes.push(int_code);
1363 }
1364
1365 // The final boundary must equal NP (ENDF §0.5: last NBT is 1-based index of last point).
1366 if nr > 0 {
1367 let last_nbt = *boundaries.last().unwrap();
1368 if last_nbt != np {
1369 return Err(EndfParseError::UnsupportedFormat(format!(
1370 "TAB1 last NBT={} does not equal NP={}",
1371 last_nbt, np
1372 )));
1373 }
1374 }
1375
1376 if np == 0 {
1377 return Err(EndfParseError::UnsupportedFormat(
1378 "TAB1 NP=0: table must have at least one point".to_string(),
1379 ));
1380 }
1381
1382 // Read NP×2 floats: (E, AP) pairs.
1383 let data_raw = parse_list_values(lines, pos, np * 2)?;
1384 let mut points = Vec::with_capacity(np);
1385 for i in 0..np {
1386 let x = data_raw[i * 2];
1387 let y = data_raw[i * 2 + 1];
1388 // x-values must be strictly increasing; Tab1::evaluate() relies on this.
1389 if let Some(&(x_prev, _)) = points.last()
1390 && x <= x_prev
1391 {
1392 return Err(EndfParseError::UnsupportedFormat(format!(
1393 "TAB1 x[{}]={} is not greater than x[{}]={} (x must be strictly increasing)",
1394 i,
1395 x,
1396 i - 1,
1397 x_prev
1398 )));
1399 }
1400 points.push((x, y));
1401 }
1402
1403 Ok(Tab1 {
1404 boundaries,
1405 interp_codes,
1406 points,
1407 })
1408}
1409
1410/// Errors from ENDF parsing.
1411#[derive(Debug, thiserror::Error)]
1412pub enum EndfParseError {
1413 #[error("Missing section: {0}")]
1414 MissingSection(String),
1415
1416 #[error("Unsupported format: {0}")]
1417 UnsupportedFormat(String),
1418
1419 #[error("Invalid number: {0}")]
1420 InvalidNumber(String),
1421
1422 #[error("Unexpected end of file: {0}")]
1423 UnexpectedEof(String),
1424
1425 #[error("Invalid isotope: {0}")]
1426 InvalidIsotope(#[from] nereids_core::error::NereidsError),
1427}
1428
1429#[cfg(test)]
1430mod tests {
1431 use super::*;
1432
1433 // NOTE: Every ENDF test fixture line must be at least 75 characters long.
1434 // The MF/MT filter in `parse_endf_file2` checks `line.len() < 75` and
1435 // discards shorter lines. ENDF lines are exactly 80 characters in the
1436 // real format. If a test fixture line is truncated below 75 chars, it
1437 // will be silently dropped and the test will fail with "No MF=2, MT=151
1438 // data found" rather than a useful error.
1439
1440 #[test]
1441 fn test_parse_endf_float_standard() {
1442 // ENDF fields are exactly 11 chars wide, no separators.
1443 // " 1.23456+2" in 11 chars = " 1.23456+02" (Fortran E11.4 style)
1444 // 01234567890 (field 0: cols 0-10, field 1: cols 11-21, etc.)
1445 let line = " 1.23456+02 2.34567-01 0.00000+00 ";
1446 assert!((parse_endf_float(line, 0).unwrap() - 123.456).abs() < 0.01);
1447 assert!((parse_endf_float(line, 1).unwrap() - 0.234567).abs() < 1e-6);
1448 assert!((parse_endf_float(line, 2).unwrap() - 0.0).abs() < 1e-10);
1449 }
1450
1451 #[test]
1452 fn test_parse_endf_float_with_e() {
1453 // 11-char fields: "1.23456E+02" "2.34567E-01"
1454 let line = "1.23456E+022.34567E-01 ";
1455 assert!((parse_endf_float(line, 0).unwrap() - 123.456).abs() < 0.01);
1456 assert!((parse_endf_float(line, 1).unwrap() - 0.234567).abs() < 1e-6);
1457 }
1458
1459 #[test]
1460 fn test_parse_endf_float_negative() {
1461 let line = "-1.23456+02-2.34567-01 ";
1462 assert!((parse_endf_float(line, 0).unwrap() - (-123.456)).abs() < 0.01);
1463 assert!((parse_endf_float(line, 1).unwrap() - (-0.234567)).abs() < 1e-6);
1464 }
1465
1466 /// Fortran exponents with a space between the sign and digit — e.g. "9.22330+ 4"
1467 /// — appear in some older ENDF evaluations (observed in SAMMY tr149/t149a.endf
1468 /// for U-233). The parser strips the space before parsing the exponent.
1469 #[test]
1470 fn test_parse_endf_float_spaced_exponent() {
1471 // " 9.22330+ 4" occupies 11 chars: space before mantissa, space before digit
1472 let line =
1473 " 9.22330+ 4 1.23400- 2 ";
1474 assert!((parse_endf_float(line, 0).unwrap() - 92_233.0).abs() < 1.0);
1475 assert!((parse_endf_float(line, 1).unwrap() - 0.01234).abs() < 1e-6);
1476 }
1477
1478 #[test]
1479 fn test_parse_endf_int() {
1480 let line = " 0 1 2 3 4 5 ";
1481 assert_eq!(parse_endf_int(line, 0).unwrap(), 0);
1482 assert_eq!(parse_endf_int(line, 1).unwrap(), 1);
1483 assert_eq!(parse_endf_int(line, 2).unwrap(), 2);
1484 }
1485
1486 /// Parse the vendored U-238 ENDF file (Reich-Moore, LRF=3).
1487 ///
1488 /// This test validates against the public-domain U-238 ENDF/B-VIII.0
1489 /// evaluation shipped at `examples/data/u238_ex027.endf` (the same
1490 /// file SAMMY distributes as `samexm_new/ex027_new/ex027.endf`). The
1491 /// first positive-energy resonance of U-238 is at 6.674 eV.
1492 ///
1493 /// Vendored under public-domain ENDF/B redistribution, so this gate
1494 /// runs unconditionally on CI — no `Skipping…` fall-through.
1495 #[test]
1496 fn test_parse_u238_sammy_endf() {
1497 // Crate-local copy so the test works when nereids-endf is built
1498 // standalone (outside the workspace, where `examples/data/` is
1499 // not packaged). The original `examples/data/u238_ex027.endf`
1500 // is kept for end-user example code.
1501 let endf_path =
1502 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/u238_ex027.endf");
1503
1504 let endf_text = std::fs::read_to_string(&endf_path)
1505 .unwrap_or_else(|e| panic!("vendored U-238 fixture missing at {endf_path:?}: {e}"));
1506 let data = parse_endf_file2(&endf_text).unwrap();
1507
1508 // Basic structure checks.
1509 assert_eq!(data.za, 92238, "Should be U-238");
1510 assert!((data.awr - 236.006).abs() < 0.01, "AWR should be ~236");
1511 assert!(!data.ranges.is_empty(), "Should have at least one range");
1512
1513 let range = &data.ranges[0];
1514 assert!(range.resolved, "First range should be resolved");
1515 assert_eq!(
1516 range.formalism,
1517 ResonanceFormalism::ReichMoore,
1518 "U-238 ENDF uses Reich-Moore (LRF=3)"
1519 );
1520 assert!(
1521 (range.target_spin - 0.0).abs() < 1e-10,
1522 "U-238 target spin I=0"
1523 );
1524 assert!(
1525 (range.scattering_radius - 9.4285).abs() < 0.01,
1526 "Scattering radius ~9.4285 fm (ENDF 0.94285 × 10)"
1527 );
1528 assert_eq!(range.l_groups.len(), 2, "Should have L=0 and L=1 groups");
1529
1530 // Check first L-group (L=0).
1531 let l0 = &range.l_groups[0];
1532 assert_eq!(l0.l, 0, "First group should be L=0");
1533 assert!(
1534 l0.resonances.len() > 500,
1535 "L=0 should have hundreds of resonances"
1536 );
1537
1538 // Find the famous 6.674 eV resonance of U-238.
1539 let first_positive = l0
1540 .resonances
1541 .iter()
1542 .find(|r| r.energy > 0.0)
1543 .expect("Should have positive-energy resonances");
1544 assert!(
1545 (first_positive.energy - 6.674).abs() < 0.01,
1546 "First positive resonance should be at 6.674 eV, got {}",
1547 first_positive.energy
1548 );
1549 assert!(
1550 (first_positive.j - 0.5).abs() < 1e-10,
1551 "6.674 eV resonance has J=0.5"
1552 );
1553
1554 // The 6.674 eV resonance neutron width: ~1.493e-3 eV
1555 assert!(
1556 (first_positive.gn - 1.493e-3).abs() < 1e-5,
1557 "Neutron width should be ~1.493e-3 eV, got {}",
1558 first_positive.gn
1559 );
1560 // Gamma width: ~2.3e-2 eV
1561 assert!(
1562 (first_positive.gg - 2.3e-2).abs() < 1e-3,
1563 "Gamma width should be ~2.3e-2 eV, got {}",
1564 first_positive.gg
1565 );
1566
1567 // The aggregate accessor must cover at least the resolved range's
1568 // resonances (it sums over every range, including any unresolved
1569 // range the file carries).
1570 let total = data.total_resonance_count();
1571 let resolved_sum: usize = range.l_groups.iter().map(|g| g.resonances.len()).sum();
1572 assert!(
1573 total >= resolved_sum && resolved_sum > 500,
1574 "total_resonance_count ({total}) must cover the resolved range's \
1575 {resolved_sum} resonances"
1576 );
1577 }
1578
1579 /// Pin the Ta-181 ENDF/B-VIII.0 resonance count at 76 (genuinely-sparse RRR).
1580 ///
1581 /// Ta-181 (MAT 7328) in ENDF/B-VIII.0 has NER=2 ranges:
1582 /// range 0: LRU=1 resolved, LRF=2 (MLBW), one L-group, 76 discrete
1583 /// resonances, resolved region only to 330 eV;
1584 /// range 1: LRU=2 unresolved (URR), 0 discrete resonances.
1585 /// So `total_resonance_count()` == 76 is **faithful**, not a dropped range —
1586 /// the parser reads every NER range and errors on unconsumed MF2/MT151 data.
1587 /// ENDF/B-VIII.1 later extended the resolved region (RRR to 2554 eV, 565
1588 /// resonances); a low VIII.0 count reflects that evaluation's resolved-region
1589 /// extent, not a parser bug. This test is a regression guard for that fact.
1590 ///
1591 /// Vendored under public-domain ENDF/B redistribution (73-Ta-181 LLNL EVAL,
1592 /// same evaluation NNDC/IAEA distribute) at the workspace-root
1593 /// `tests/data/endf/Ta-181.endf` (Hf-177 precedent). Inside the full NEREIDS
1594 /// workspace — where CI runs — the fixture is always present and the gate
1595 /// asserts fully; a standalone crate build (no workspace fixtures) skips.
1596 #[test]
1597 fn test_parse_ta181_endf8_0_resonance_count() {
1598 let endf_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1599 .parent()
1600 .unwrap()
1601 .parent()
1602 .unwrap()
1603 .join("tests/data/endf/Ta-181.endf");
1604 // The fixture is committed to the repo, so a missing file is a
1605 // packaging/path regression that MUST fail CI — not a skip that would
1606 // silently disable this #638 resonance-count guard.
1607 let endf_text = std::fs::read_to_string(&endf_path).unwrap_or_else(|e| {
1608 panic!(
1609 "vendored Ta-181 regression fixture must be present at \
1610 {endf_path:?} (committed test data): {e}"
1611 )
1612 });
1613 let data = parse_endf_file2(&endf_text).unwrap();
1614
1615 assert_eq!(data.za, 73181, "Should be Ta-181");
1616 assert!(
1617 (data.awr - 179.3936).abs() < 0.01,
1618 "AWR should be ~179.3936, got {}",
1619 data.awr
1620 );
1621
1622 // Faithful count: VIII.0's RRR is genuinely sparse (76), not a drop.
1623 assert_eq!(
1624 data.total_resonance_count(),
1625 76,
1626 "Ta-181 VIII.0 has 76 discrete resonances (sparse RRR to 330 eV)"
1627 );
1628 assert_eq!(
1629 data.ranges.len(),
1630 2,
1631 "Ta-181 VIII.0 has NER=2 ranges (resolved MLBW + unresolved URR)"
1632 );
1633
1634 // Range 0: resolved MLBW (LRF=2), all 76 resonances live here.
1635 let resolved = &data.ranges[0];
1636 assert!(resolved.resolved, "Range 0 must be resolved (LRU=1)");
1637 assert_eq!(
1638 resolved.formalism,
1639 ResonanceFormalism::MLBW,
1640 "Ta-181 VIII.0 resolved range uses MLBW (LRF=2)"
1641 );
1642 assert_eq!(
1643 resolved.resonance_count(),
1644 76,
1645 "All 76 resonances belong to the resolved MLBW range"
1646 );
1647
1648 // Range 1: unresolved (LRU=2, URR), zero discrete resonances.
1649 let unresolved = &data.ranges[1];
1650 assert!(!unresolved.resolved, "Range 1 must be unresolved (LRU=2)");
1651 assert_eq!(
1652 unresolved.formalism,
1653 ResonanceFormalism::Unresolved,
1654 "Ta-181 VIII.0 second range is the URR (LRU=2)"
1655 );
1656 assert_eq!(
1657 unresolved.resonance_count(),
1658 0,
1659 "URR range carries no discrete resonances"
1660 );
1661
1662 // Mixed evaluation: the resolved MLBW range keeps the file loadable,
1663 // and the skipped URR range must be flagged as unevaluated so callers
1664 // can warn that its span contributes zero cross-section.
1665 assert!(
1666 data.has_unevaluated_ranges(),
1667 "Ta-181's URR range must be flagged as unevaluated"
1668 );
1669 assert_eq!(
1670 data.unevaluated_ranges().len(),
1671 1,
1672 "exactly the URR range is unevaluated"
1673 );
1674 }
1675
1676 /// Assert that a fixture whose every range is a parse-and-skip placeholder
1677 /// is rejected with the no-evaluable-ranges error.
1678 ///
1679 /// This still pins cursor advancement: a misaligned skip surfaces as a
1680 /// *different* error (multi-material guard, unexpected EOF, or a
1681 /// structural-guard message), not this one — so reaching this specific
1682 /// message proves the skip consumed exactly the range body.
1683 #[track_caller]
1684 fn assert_rejected_no_evaluable_ranges(endf: &str) {
1685 let err = parse_endf_file2(endf).unwrap_err();
1686 match err {
1687 EndfParseError::UnsupportedFormat(ref msg) => {
1688 assert!(
1689 msg.contains("No evaluable resonance ranges"),
1690 "expected no-evaluable-ranges rejection, got: {msg}"
1691 );
1692 }
1693 other => panic!("expected UnsupportedFormat, got: {other:?}"),
1694 }
1695 }
1696
1697 /// A valid KRM=3 LRF=7 range is parsed-and-skipped, and because it is the
1698 /// file's ONLY range the parse is rejected with the no-evaluable-ranges
1699 /// error (a pure-LRF=7 evaluation would yield zero cross-sections
1700 /// everywhere). The fixture is a minimal but fully valid ENDF MF=2/MT=151
1701 /// block (1 isotope, 1 range, LRF=7, KRM=3, 1 particle pair, 1 spin group,
1702 /// 2 resonances, NCH=1); reaching the no-evaluable-ranges error (rather
1703 /// than a misalignment error) exercises the KRM=3 stride (NCH+2) in the
1704 /// skip's resonance-block consumption.
1705 #[test]
1706 fn test_lrf7_krm3_rejected_no_evaluable_ranges() {
1707 const ENDF: &str =
1708 include_str!("../../../tests/data/synthetic/lrf7_krm3_resonance_column_order.endf");
1709 assert_rejected_no_evaluable_ranges(ENDF);
1710 }
1711
1712 /// The no-evaluable-ranges rejection must NAME each skipped range's
1713 /// formalism and energy span so the user can see exactly what was in the
1714 /// file and why it cannot be evaluated.
1715 #[test]
1716 fn test_lrf7_rejection_names_formalism_and_span() {
1717 const ENDF: &str =
1718 include_str!("../../../tests/data/synthetic/lrf7_krm3_resonance_column_order.endf");
1719 let err = parse_endf_file2(ENDF).unwrap_err();
1720 let msg = err.to_string();
1721 assert!(
1722 msg.contains("LRF=7 (R-Matrix Limited)"),
1723 "rejection must name the skipped formalism, got: {msg}"
1724 );
1725 assert!(
1726 msg.contains("eV"),
1727 "rejection must state the skipped energy span, got: {msg}"
1728 );
1729 assert!(
1730 msg.contains("LRF=1/2/3"),
1731 "rejection must state what NEREIDS evaluates, got: {msg}"
1732 );
1733 }
1734
1735 /// A KRM=2 LRF=7 range with an explicit photon capture channel (IPP=2,
1736 /// MA=0, MT=102) passes every particle-pair, IPP-range, and stride guard
1737 /// during the skip — then the file is rejected because the LRF=7 range is
1738 /// its only range (no evaluable content).
1739 #[test]
1740 fn test_lrf7_krm2_photon_channel_rejected_no_evaluable_ranges() {
1741 // Two particle pairs: pair 1 = n+W184 (MT=2), pair 2 = γ+W185 (MT=102, MA=0);
1742 // one spin group with 2 channels (elastic + photon); one resonance.
1743 const ENDF: &str =
1744 include_str!("../../../tests/data/synthetic/lrf7_krm2_explicit_photon_channel.endf");
1745 assert_rejected_no_evaluable_ranges(ENDF);
1746 }
1747
1748 /// Parse a minimal hand-crafted ENDF snippet with NRO=1 (energy-dependent
1749 /// scattering radius).
1750 ///
1751 /// The fixture encodes:
1752 /// - LRF=3 (Reich-Moore), NRO=1
1753 /// - AP TAB1: 2 points — ENDF values 8.0 and 10.0 (10⁻¹² cm),
1754 /// which become 80.0 fm and 100.0 fm after ×10 conversion
1755 /// - One L-group (L=0) with one resonance at 6.674 eV
1756 ///
1757 /// Verifies:
1758 /// - ap_table is Some after parsing
1759 /// - ap_table.evaluate(1.0) ≈ 80.0 fm (8.0 × ENDF_RADIUS_TO_FM)
1760 /// - ap_table.evaluate(500.5) ≈ 90.0 fm (midpoint, lin-lin)
1761 /// - ap_table.evaluate(1000.0) ≈ 100.0 fm
1762 /// - scattering_radius_at() delegates to the table
1763 #[test]
1764 fn test_parse_nro1_tab1() {
1765 // Each ENDF line is exactly 80 chars: 66 data chars + 14 MAT/MF/MT/SEQ.
1766 // Cols 67-70: MAT=9237, Cols 71-72: MF=2, Cols 73-75: MT=151, Cols 76-80: seq
1767 //
1768 // Line layout (11 chars per field × 6 fields = 66 chars, then 14 control chars):
1769 // HEAD: ZA=92238 AWR=236.006 0 0 NIS=1 0
1770 // CONT: ZAI=92238 ABN=1.0 0 LFW=0 NER=1 0
1771 // CONT: EL=1e-5 EH=1e4 LRU=1 LRF=3 NRO=1 NAPS=0
1772 // TAB1 CONT: 0 0 0 0 NR=1 NP=2
1773 // TAB1 interp: NBT=2, INT=2 (plus 4 padding zeros)
1774 // TAB1 data: (1.0, 8.0), (1000.0, 10.0)
1775 // RM CONT: SPI=0.0 AP=9.0 0 0 NLS=1 0
1776 // L CONT: AWRI=236.006 0 L=0 0 6*NRS=6 NRS=1
1777 // Resonance: ER=6.674 AJ=0.5 GN=1.493e-3 GG=23e-3 GFA=0 GFB=0
1778 // SEND: all zeros
1779 // Each ENDF line: 66 data chars + 4-char MAT(9237) + 2-char MF(" 2")
1780 // + 3-char MT("151") + 5-char SEQ = 80 chars total.
1781 let endf = include_str!("../../../tests/data/synthetic/lrf3_nro1_tab1.endf");
1782
1783 let data = parse_endf_file2(endf).expect("NRO=1 fixture must parse cleanly");
1784 assert_eq!(data.ranges.len(), 1, "one energy range");
1785
1786 let range = &data.ranges[0];
1787 assert_eq!(
1788 range.formalism,
1789 ResonanceFormalism::ReichMoore,
1790 "must be LRF=3"
1791 );
1792
1793 let table = range
1794 .ap_table
1795 .as_ref()
1796 .expect("NRO=1 range must have ap_table");
1797 assert_eq!(table.points.len(), 2, "TAB1 must have 2 points");
1798
1799 // Exact boundary values (ENDF 8.0 × 10 = 80.0 fm).
1800 assert!(
1801 (table.evaluate(1.0) - 80.0).abs() < 1e-10,
1802 "AP(1 eV) = 80.0 fm"
1803 );
1804 assert!(
1805 (table.evaluate(1000.0) - 100.0).abs() < 1e-10,
1806 "AP(1000 eV) = 100.0 fm"
1807 );
1808 // Lin-lin midpoint: AP(500.5 eV) ≈ 90.0 fm.
1809 let mid = table.evaluate(500.5);
1810 assert!((mid - 90.0).abs() < 0.1, "AP midpoint ≈ 90.0 fm, got {mid}");
1811
1812 // scattering_radius_at delegates to the table.
1813 assert!(
1814 (range.scattering_radius_at(1.0) - 80.0).abs() < 1e-10,
1815 "scattering_radius_at(1 eV) = 80.0"
1816 );
1817 assert!(
1818 (range.scattering_radius_at(1000.0) - 100.0).abs() < 1e-10,
1819 "scattering_radius_at(1000 eV) = 100.0"
1820 );
1821
1822 // Resonance is still parsed correctly.
1823 assert_eq!(range.l_groups.len(), 1, "one L-group");
1824 let res = &range.l_groups[0].resonances[0];
1825 assert!((res.energy - 6.674).abs() < 1e-6);
1826 }
1827
1828 /// LFW=1 with LRF=2 (tabulated widths, U-233-style record).
1829 ///
1830 /// SAMMY test tr149 (`t149a.endf`, MAT=9222, ZA=92233) has two ranges:
1831 /// - Range 0: LRU=1 (resolved, Reich-Moore / LRF=3)
1832 /// - Range 1: LRU=2, LRF=2, **LFW=1** (energy-dependent fission widths)
1833 ///
1834 /// ENDF-6 §2.2.2.2: for LFW=1/LRF=2 the per-(L,J) LIST layout is
1835 /// **identical to LFW=0/LRF=2** (the fission widths are already
1836 /// per-energy-point in the LIST tail), so the parser dispatches to the
1837 /// shared `skip_urr_range` path: the URR body is parsed-and-skipped
1838 /// (structural guards applied, values discarded) and the range is
1839 /// stored as a non-evaluable placeholder.
1840 ///
1841 /// We previously gated this assertion on a `../SAMMY/...t149a.endf`
1842 /// sibling checkout; on CI (and on any clean clone) the file was
1843 /// absent and the test silently reported `ok` after a `Skipping…`
1844 /// print. Vendoring the full tr149 ENDF would be heavy; instead we
1845 /// synthesise a minimal but record-shape-faithful LFW=1/LRF=2
1846 /// fixture so the assertion runs unconditionally.
1847 #[test]
1848 fn test_parse_u233_lfw1_lrf2_urr_parsed() {
1849 // Minimal MF=2/MT=151 with two ranges, mirroring tr149 layout:
1850 // Range 0: LRU=1, LRF=3 (Reich-Moore) — one trivial resonance.
1851 // Range 1: LRU=2, LRF=2, LFW=1 — NLS=1, NJS=1, NE=2.
1852 // LFW=1 is flagged on the isotope CONT (L2 field).
1853 const ENDF: &str = include_str!("../../../tests/data/synthetic/u233_lfw1_lrf2_urr.endf");
1854
1855 let data = parse_endf_file2(ENDF)
1856 .expect("U-233 LFW=1/LRF=2 fixture must parse (record layout = LFW=0/LRF=2)");
1857
1858 // Both ranges must round-trip: resolved + URR.
1859 assert_eq!(data.ranges.len(), 2, "must have 2 ranges (resolved + URR)");
1860
1861 let resolved_count = data.ranges.iter().filter(|r| r.resolved).count();
1862 assert_eq!(resolved_count, 1, "exactly one resolved range");
1863
1864 // The URR range is parsed-and-skipped: it must appear as a
1865 // non-resolved Unresolved range. LFW=1/LRF=2 shares the LFW=0/LRF=2
1866 // record layout, so a mis-sized skip would trip the multi-material
1867 // guard or drop the resolved range — the counts above pin alignment.
1868 let urr_count = data
1869 .ranges
1870 .iter()
1871 .filter(|r| r.formalism == ResonanceFormalism::Unresolved)
1872 .count();
1873 assert_eq!(
1874 urr_count, 1,
1875 "LFW=1/LRF=2 URR range must be present (skipped)"
1876 );
1877 let urr_range = data
1878 .ranges
1879 .iter()
1880 .find(|r| r.formalism == ResonanceFormalism::Unresolved)
1881 .expect("URR range must exist");
1882 assert!(!urr_range.resolved, "URR range must not be resolved");
1883
1884 // Mixed evaluation: the resolved range keeps the file loadable, and
1885 // the skipped URR range must be flagged as unevaluated.
1886 assert!(data.has_unevaluated_ranges());
1887 assert_eq!(data.unevaluated_ranges().len(), 1);
1888 }
1889
1890 /// Hand-crafted LRF=1 URR roundtrip test.
1891 ///
1892 /// Verifies that a minimal synthetic ENDF snippet with LRU=2, LRF=1 is
1893 /// parsed correctly: one L-group (L=0), two J-groups with known D, AJ,
1894 /// AMUN, GNO, GG, GF values.
1895 #[test]
1896 fn test_parse_lrf1_urr_roundtrip() {
1897 // Minimal ENDF MF=2/MT=151 with one resolved range followed by one
1898 // LRU=2 LRF=1 unresolved range.
1899 //
1900 // Resolved range: a simple RM LRF=3 with one resonance (gives the
1901 // parser something valid to consume before the URR section).
1902 //
1903 // URR range: LRU=2, LRF=1, NLS=1 (L=0), NJS=2 J-groups.
1904 // J=2.0: D=0.5 eV, AMUN=1, GNO=3e-4 eV, GG=3.5e-2 eV, GF=0
1905 // J=3.0: D=0.4 eV, AMUN=1, GNO=2e-4 eV, GG=3.0e-2 eV, GF=1e-3 eV
1906 //
1907 // Each ENDF line: 66 data chars + MAT(4) MF(2) MT(3) SEQ(5) = 80 chars.
1908 const ENDF: &str = include_str!("../../../tests/data/synthetic/lrf1_urr_roundtrip.endf");
1909
1910 let data = parse_endf_file2(ENDF).expect("LRF=1 URR fixture must parse cleanly");
1911
1912 // Should have 2 ranges: one resolved + one URR.
1913 assert_eq!(data.ranges.len(), 2, "must have 2 ranges");
1914
1915 let urr_range = &data.ranges[1];
1916 assert!(!urr_range.resolved, "URR range must not be resolved");
1917 assert_eq!(
1918 urr_range.formalism,
1919 ResonanceFormalism::Unresolved,
1920 "formalism must be Unresolved"
1921 );
1922
1923 // The LRF=1 URR body is parsed-and-skipped; the range is present as
1924 // Unresolved (asserted above). ranges.len()==2 confirms the skip
1925 // consumed exactly the URR body and left the line stream aligned.
1926
1927 // Mixed evaluation: the resolved range keeps the file loadable, and
1928 // the skipped URR range must be flagged as unevaluated.
1929 assert!(data.has_unevaluated_ranges());
1930 assert_eq!(data.unevaluated_ranges().len(), 1);
1931 }
1932
1933 /// An LRF=2 URR range with INT=3 (log-x/lin-y) passes the per-J INT guard
1934 /// (§0.5: INT∈1..=5 — a stricter guard would spuriously reject valid
1935 /// evaluations), then the file is rejected because the URR range is its
1936 /// only range (no evaluable content).
1937 #[test]
1938 fn test_parse_lrf2_urr_int3_rejected_no_evaluable_ranges() {
1939 // Minimal ENDF MF=2/MT=151 with one LRU=2/LRF=2 range:
1940 // NLS=1 (L=0), NJS=1, NE=2 energy points, INT=3 (log-x/lin-y).
1941 // LIST layout: row 0 = [0, 0, 0, AMUN, 0, AMUF]; rows 1..=NE = (E,
1942 // D, GX, GN, GG, GF). Total = 6*(NE+1) = 18 floats = 3 lines.
1943 // Reaching the no-evaluable-ranges error (not an INT rejection or a
1944 // misalignment error) proves the INT=3 guard accepted the record and
1945 // the skip consumed exactly the URR body.
1946 const ENDF: &str =
1947 include_str!("../../../tests/data/synthetic/lrf2_urr_int3_roundtrip.endf");
1948 assert_rejected_no_evaluable_ranges(ENDF);
1949 }
1950
1951 /// LRF=2 URR with INT=0 is rejected as a hard error.
1952 ///
1953 /// ENDF-6 §0.5 defines INT codes 1..=5 only. INT=0 is malformed,
1954 /// not merely unsupported, so the parser surfaces it as
1955 /// `UnsupportedFormat("INT=0 out of spec (expected 1..=5)")` rather
1956 /// than panicking or silently defaulting to lin-lin.
1957 #[test]
1958 fn test_parse_lrf2_urr_int0_rejected() {
1959 // Same skeleton as INT=3 test, but with INT=0 in the J CONT.
1960 const ENDF: &str =
1961 include_str!("../../../tests/data/synthetic/lrf2_urr_int0_rejected.endf");
1962
1963 let err = parse_endf_file2(ENDF).unwrap_err();
1964 // Match on the error variant structurally so the test only
1965 // depends on the parser surfacing `UnsupportedFormat`, not on
1966 // the exact `Display` wording of `EndfParseError`. Then check
1967 // the payload string for the diagnostic substrings.
1968 match err {
1969 EndfParseError::UnsupportedFormat(ref msg) => {
1970 assert!(
1971 msg.contains("INT=0") && msg.contains("out of spec"),
1972 "expected INT-out-of-spec rejection in UnsupportedFormat payload, \
1973 got: {msg}"
1974 );
1975 }
1976 other => panic!("expected EndfParseError::UnsupportedFormat for INT=0, got: {other:?}"),
1977 }
1978 }
1979
1980 // -----------------------------------------------------------------------
1981 // Issue #123: robustness tests for malformed input validation
1982 // -----------------------------------------------------------------------
1983
1984 /// `checked_count` rejects negative values.
1985 #[test]
1986 fn test_checked_count_negative() {
1987 let err = checked_count(-1, "NLS").unwrap_err();
1988 assert!(
1989 err.to_string().contains("Negative"),
1990 "expected negative error, got: {err}"
1991 );
1992 }
1993
1994 /// `checked_count` rejects values above `MAX_ENDF_COUNT` to prevent
1995 /// allocation bombs from malformed files.
1996 #[test]
1997 fn test_checked_count_upper_bound() {
1998 // Just above the limit.
1999 let err = checked_count(MAX_ENDF_COUNT + 1, "NRS").unwrap_err();
2000 assert!(
2001 err.to_string().contains("too large"),
2002 "expected upper-bound error, got: {err}"
2003 );
2004
2005 // At the limit: should succeed.
2006 assert_eq!(checked_count(MAX_ENDF_COUNT, "NRS").unwrap(), 1_000_000);
2007
2008 // i32::MAX: should be rejected.
2009 let err = checked_count(i32::MAX, "NPL").unwrap_err();
2010 assert!(
2011 err.to_string().contains("too large"),
2012 "expected upper-bound error for i32::MAX, got: {err}"
2013 );
2014 }
2015
2016 /// `parse_endf_int` rejects non-integral float values rather than
2017 /// silently truncating.
2018 ///
2019 /// Without the strict check, an INT-field value stored as
2020 /// `"1.900000+0"` would be cast as `1.9_f64 as i32 == 1`, masking
2021 /// a malformed evaluation. After the strict check, the parser
2022 /// surfaces `InvalidNumber` immediately.
2023 ///
2024 /// The two field layouts:
2025 /// • field 0: integral float "1.000000+0" → returns Ok(1)
2026 /// • field 1: non-integral "1.900000+0" → returns Err(InvalidNumber)
2027 /// must both round-trip the standard ENDF integer-as-float encoding.
2028 #[test]
2029 fn test_parse_endf_int_rejects_non_integral_float() {
2030 // 11-char fields, padded with leading space to match the ENDF column
2031 // width. Field 0 is integral; field 1 is non-integral.
2032 const LINE: &str = " 1.000000+0 1.900000+0";
2033
2034 // Integral float must parse cleanly.
2035 let ok = parse_endf_int(LINE, 0).expect("integral float must parse");
2036 assert_eq!(ok, 1);
2037
2038 // Non-integral float must be rejected, not truncated to 1.
2039 let err = parse_endf_int(LINE, 1).expect_err("non-integral must be rejected");
2040 match err {
2041 EndfParseError::InvalidNumber(ref msg) => {
2042 assert!(
2043 msg.contains("Non-integral"),
2044 "expected Non-integral diagnostic, got: {msg}"
2045 );
2046 }
2047 other => panic!("expected InvalidNumber, got: {other:?}"),
2048 }
2049 }
2050
2051 /// Negative L-value in a Breit-Wigner range is rejected.
2052 ///
2053 /// Constructs a minimal SLBW fixture with L=-1 in the L-group CONT record.
2054 /// Without the validation, `l_cont.l1 as u32` would wrap to `u32::MAX`.
2055 #[test]
2056 fn test_bw_negative_l_rejected() {
2057 // Minimal SLBW fixture: HEAD + isotope CONT + range CONT + SPI/AP CONT +
2058 // L-group CONT with L=-1 (field 3 = -1).
2059 const ENDF: &str =
2060 include_str!("../../../tests/data/synthetic/lrf1_bw_negative_l_rejected.endf");
2061
2062 let err = parse_endf_file2(ENDF).unwrap_err();
2063 assert!(
2064 err.to_string().contains("negative L"),
2065 "expected negative L error, got: {err}"
2066 );
2067 }
2068
2069 /// Negative L-value in a Reich-Moore range is rejected.
2070 #[test]
2071 fn test_rm_negative_l_rejected() {
2072 const ENDF: &str =
2073 include_str!("../../../tests/data/synthetic/lrf3_rm_negative_l_rejected.endf");
2074
2075 let err = parse_endf_file2(ENDF).unwrap_err();
2076 assert!(
2077 err.to_string().contains("negative L"),
2078 "expected negative L error, got: {err}"
2079 );
2080 }
2081
2082 /// LRU=0 range with non-zero NLS is rejected.
2083 ///
2084 /// ENDF-6 §2.2 says the SPI/AP CONT after an LRU=0 range must have
2085 /// NLS=0 (no L-groups for scattering-radius-only ranges).
2086 #[test]
2087 fn test_lru0_nonzero_nls_rejected() {
2088 const ENDF: &str =
2089 include_str!("../../../tests/data/synthetic/lru0_nonzero_nls_rejected.endf");
2090
2091 let err = parse_endf_file2(ENDF).unwrap_err();
2092 assert!(
2093 err.to_string().contains("NLS=3"),
2094 "expected LRU=0 NLS validation error, got: {err}"
2095 );
2096 }
2097
2098 /// A resolved (LRU=1) MLBW range with NLS=0 is rejected.
2099 ///
2100 /// ENDF-6 §2.2.1.1 requires NLS >= 1 in a resolved range (NLS=0 is
2101 /// reserved for LRU=0 scattering-radius-only stanzas). Without the guard
2102 /// this file loads as "evaluable" with zero resonances — zero
2103 /// cross-section everywhere and no warning.
2104 #[test]
2105 fn test_mlbw_nls0_rejected() {
2106 const ENDF: &str = include_str!("../../../tests/data/synthetic/mlbw_nls0_rejected.endf");
2107
2108 let err = parse_endf_file2(ENDF).unwrap_err();
2109 assert!(
2110 err.to_string().contains("NLS=0"),
2111 "expected resolved NLS=0 rejection, got: {err}"
2112 );
2113 }
2114
2115 /// A file whose ONLY range is resolved with NLS=1 but NRS=0 in every
2116 /// L-group is rejected by the no-evaluable-content guard, naming the
2117 /// inert span. (The range itself is accepted as a warn-and-skip
2118 /// placeholder — SAMMY/OpenScale accept NRS=0 L-groups — so the
2119 /// rejection comes from the file-level guard, not a per-range error.)
2120 #[test]
2121 fn test_mlbw_nrs0_everywhere_rejected() {
2122 const ENDF: &str =
2123 include_str!("../../../tests/data/synthetic/mlbw_nrs0_everywhere_rejected.endf");
2124
2125 let err = parse_endf_file2(ENDF).unwrap_err();
2126 let msg = err.to_string();
2127 assert!(
2128 msg.contains("No evaluable resonance ranges"),
2129 "expected no-evaluable-content rejection, got: {msg}"
2130 );
2131 assert!(
2132 msg.contains("resolved range with no resonances"),
2133 "rejection must name the inert resolved span, got: {msg}"
2134 );
2135 }
2136
2137 /// A mixed file [populated resolved range][all-empty resolved range]
2138 /// loads: the populated range stays evaluable, the inert range becomes a
2139 /// warn-and-skip placeholder — symmetric with LRF=7/LRU=2/LRU=0 handling,
2140 /// so the good range is not lost.
2141 #[test]
2142 fn test_mlbw_nrs0_plus_resolved_mixed_loads_and_flags() {
2143 const ENDF: &str =
2144 include_str!("../../../tests/data/synthetic/mlbw_nrs0_plus_resolved_mixed.endf");
2145
2146 let data = parse_endf_file2(ENDF).expect("mixed populated + inert file must load");
2147 assert_eq!(data.ranges.len(), 2);
2148 assert!(data.has_evaluable_range());
2149 assert!(data.has_unevaluated_ranges());
2150 assert_eq!(data.total_resonance_count(), 1);
2151 let inert = data
2152 .ranges
2153 .iter()
2154 .find(|r| !r.is_evaluable())
2155 .expect("the inert range must be present");
2156 assert!(
2157 inert
2158 .skip_description()
2159 .contains("LRF=2 (MLBW) resolved range with no resonances"),
2160 "skip_description must name the inert shape, got: {}",
2161 inert.skip_description()
2162 );
2163 }
2164
2165 /// A resolved (LRU=1) Reich-Moore range with NLS=0 is rejected.
2166 #[test]
2167 fn test_rm_nls0_rejected() {
2168 const ENDF: &str = include_str!("../../../tests/data/synthetic/lrf3_rm_nls0_rejected.endf");
2169
2170 let err = parse_endf_file2(ENDF).unwrap_err();
2171 assert!(
2172 err.to_string().contains("NLS=0"),
2173 "expected resolved NLS=0 rejection, got: {err}"
2174 );
2175 }
2176
2177 /// LFW=1/LRF=1 (energy-dependent fission widths) URR is skipped with the
2178 /// line stream left aligned.
2179 ///
2180 /// ENDF-6 §2.2.2.1 Case B: a shared NE-point energy grid is followed,
2181 /// for each (L, J), by a full LIST record — a control line
2182 /// `[0.0, 0.0, L, MUF, NE+6, 0]` and then a body
2183 /// `[D, AJ, AMUN, GNO, GG, 0] + GF(1..NE)`. The per-J control line MUST
2184 /// be consumed before the body; otherwise the line stream misaligns by
2185 /// one record per J-group and the file fails to parse (the stray lines
2186 /// trip the multi-material guard).
2187 ///
2188 /// This fixture is standards-compliant (it includes the per-J control
2189 /// line); the assertion that it parses end-to-end is the regression guard
2190 /// for the Case-B skip's cursor advancement.
2191 ///
2192 /// The fixture has NE=2, NLS=1, NJS=1, MUF=1.
2193 #[test]
2194 fn test_lfw1_lrf1_urr_rejected_no_evaluable_ranges() {
2195 const ENDF: &str =
2196 include_str!("../../../tests/data/synthetic/lfw1_urr_gracefully_skipped.endf");
2197
2198 // The Case-B URR body is parsed-and-skipped; consuming the per-J
2199 // control line (N1=NE+6) keeps the stream aligned. The fixture's only
2200 // range is the URR, so the aligned parse then hits the
2201 // no-evaluable-ranges rejection — a misaligned skip would instead trip
2202 // the multi-material guard or EOF (see the fixture note above), so
2203 // reaching this specific error pins the cursor advancement.
2204 assert_rejected_no_evaluable_ranges(ENDF);
2205 }
2206
2207 /// A per-J LIST control whose `N1 != NE+6` is a malformed Case-B record.
2208 /// The parser must reject it — this covers the per-J N1 validation guard
2209 /// (the SCALE `list.getN1()-6 == ener.getNtot()` relation). The fixture is
2210 /// byte-identical to the valid one except the per-J control N1 (8 -> 7).
2211 #[test]
2212 fn test_lfw1_lrf1_urr_rejects_bad_perj_n1() {
2213 const ENDF: &str = include_str!("../../../tests/data/synthetic/lfw1_urr_bad_perj_n1.endf");
2214
2215 let err = parse_endf_file2(ENDF).unwrap_err();
2216 assert!(
2217 err.to_string().contains("N1=") && err.to_string().contains("NE+6"),
2218 "expected per-J N1 != NE+6 rejection, got: {err}"
2219 );
2220 }
2221
2222 /// An LRU=2 (URR) range with LRF ∉ {1, 2} is a malformed record and is
2223 /// rejected with an error naming the offending LRF.
2224 ///
2225 /// ENDF-6 §2.2.2 restricts the unresolved region to LRF=1 or LRF=2. The
2226 /// parser previously heuristically consumed such a body and dropped the
2227 /// span silently; it now hard-errors like the sibling malformed-record
2228 /// guards.
2229 #[test]
2230 fn test_urr_lrf3_rejected() {
2231 const ENDF: &str = include_str!("../../../tests/data/synthetic/urr_lrf3_rejected.endf");
2232
2233 let err = parse_endf_file2(ENDF).unwrap_err();
2234 let msg = err.to_string();
2235 assert!(
2236 msg.contains("LRF=3") && msg.contains("URR"),
2237 "expected URR LRF=3 rejection naming the LRF, got: {msg}"
2238 );
2239 }
2240
2241 /// LRU=0 range with non-zero L1 in SPI/AP CONT is rejected.
2242 ///
2243 /// ENDF-6 §2.2: the SPI/AP CONT record for LRU=0 must be
2244 /// [SPI, AP, 0, 0, NLS=0, 0]. Non-zero L1 or L2 indicates a
2245 /// malformed or mis-identified record.
2246 #[test]
2247 fn test_lru0_nonzero_l1_rejected() {
2248 const ENDF: &str =
2249 include_str!("../../../tests/data/synthetic/lru0_nonzero_l1_rejected.endf");
2250
2251 let err = parse_endf_file2(ENDF).unwrap_err();
2252 assert!(
2253 err.to_string().contains("L1=5"),
2254 "expected LRU=0 L1 validation error, got: {err}"
2255 );
2256 }
2257
2258 /// A well-formed LRU=0-only evaluation (scattering radius, no resonance
2259 /// parameters) is rejected — NEREIDS cannot evaluate it and loading it
2260 /// would yield silent zero cross-sections — but the message NAMES the
2261 /// LRU=0 span rather than misreporting an empty file.
2262 ///
2263 /// ENDF-6 §2.1: LRU=0 gives a scattering radius but no resonances. The
2264 /// fixture is byte-identical to `lru0_nonzero_nls_rejected` except the
2265 /// SPI/AP CONT carries NLS=0 (well-formed), so parsing reaches the
2266 /// no-evaluable-content guard rather than the NLS validation guard.
2267 #[test]
2268 fn test_lru0_only_rejected_names_lru0_span() {
2269 const ENDF: &str =
2270 include_str!("../../../tests/data/synthetic/lru0_only_rejected_no_evaluable.endf");
2271
2272 let err = parse_endf_file2(ENDF).unwrap_err();
2273 let msg = err.to_string();
2274 assert!(
2275 msg.contains("No evaluable resonance ranges"),
2276 "expected no-evaluable-ranges rejection, got: {msg}"
2277 );
2278 assert!(
2279 msg.contains("LRU=0 (scattering-radius-only, no resonance parameters)"),
2280 "rejection must name the LRU=0 span, got: {msg}"
2281 );
2282 }
2283
2284 /// A mixed evaluation carrying an LRU=0 range plus a resolved range still
2285 /// loads: the resolved range keeps the file evaluable, and the LRU=0 span
2286 /// is surfaced as a non-evaluable parse-and-skip placeholder (same handling
2287 /// as LRF=7 / LRU=2).
2288 #[test]
2289 fn test_lru0_plus_resolved_mixed_loads_and_flags_lru0() {
2290 const ENDF: &str =
2291 include_str!("../../../tests/data/synthetic/lru0_plus_resolved_mixed.endf");
2292
2293 let data = parse_endf_file2(ENDF).expect("mixed LRU=0 + resolved file must load");
2294 assert_eq!(data.ranges.len(), 2, "LRU=0 range + resolved range");
2295 assert!(
2296 data.has_evaluable_range(),
2297 "resolved range keeps it evaluable"
2298 );
2299 assert!(data.has_unevaluated_ranges(), "LRU=0 range is unevaluated");
2300
2301 let lru0 = data
2302 .ranges
2303 .iter()
2304 .find(|r| r.formalism == ResonanceFormalism::ScatteringRadiusOnly)
2305 .expect("the LRU=0 range must be present");
2306 assert!(!lru0.resolved, "LRU=0 range must not be resolved");
2307 assert!(
2308 lru0.skip_description()
2309 .contains("LRU=0 (scattering-radius-only, no resonance parameters)"),
2310 "LRU=0 skip_description must name the span, got: {}",
2311 lru0.skip_description()
2312 );
2313
2314 // The fixture gives the placeholder DIFFERENT SPI/AP (1.5 / 5.0 fm)
2315 // than the resolved range (2.5 / 9.6931 fm), so consumers that must
2316 // prefer the evaluable range are distinguishable from ones reading
2317 // the placeholder.
2318 assert_eq!(lru0.target_spin, 1.5);
2319 assert_eq!(lru0.scattering_radius, 5.0);
2320 let resolved = data
2321 .ranges
2322 .iter()
2323 .find(|r| r.is_evaluable())
2324 .expect("the resolved range must be evaluable");
2325 assert_eq!(resolved.target_spin, 2.5);
2326 assert!((resolved.scattering_radius - 9.6931).abs() < 1e-9);
2327 }
2328
2329 /// N1 != 6*NRS in a BW range CONT is rejected.
2330 #[test]
2331 fn test_bw_n1_nrs_mismatch_rejected() {
2332 const ENDF: &str =
2333 include_str!("../../../tests/data/synthetic/lrf1_bw_n1_nrs_mismatch_rejected.endf");
2334
2335 let err = parse_endf_file2(ENDF).unwrap_err();
2336 assert!(
2337 err.to_string().contains("N1=7"),
2338 "expected N1/NRS mismatch error, got: {err}"
2339 );
2340 }
2341
2342 /// Multi-MAT detection: unconsumed MF=2/MT=151 lines after the first
2343 /// material are rejected.
2344 #[test]
2345 fn test_multi_mat_detection() {
2346 // A valid single-range SLBW file with an extra trailing data line
2347 // that still carries MF=2/MT=151 tags.
2348 const ENDF: &str = include_str!("../../../tests/data/synthetic/multi_mat_detection.endf");
2349
2350 let err = parse_endf_file2(ENDF).unwrap_err();
2351 assert!(
2352 err.to_string().contains("Multiple materials"),
2353 "expected multi-MAT error, got: {err}"
2354 );
2355 }
2356
2357 /// MF=2 NIS>1 multi-isotope materials are rejected.
2358 ///
2359 /// ENDF-6 §2.1 allows a single material to carry several isotopes, each
2360 /// with its own ZAI/ABN/NER subsection. NEREIDS's `ResonanceData` cannot
2361 /// represent that hierarchy without losing per-isotope abundance weights;
2362 /// rather than silently flatten the ranges into one isotope, the parser
2363 /// returns `UnsupportedFormat` so a downstream consumer cannot be tricked
2364 /// into computing an abundance-blind cross section.
2365 ///
2366 /// The synthetic fixture is a minimal two-isotope SLBW material: Cu-63
2367 /// (ZAI=29063, ABN=0.6917) and Cu-65 (ZAI=29065, ABN=0.3083), each with
2368 /// a single L=0 resonance. The parser should reject the file as soon as
2369 /// it reads the HEAD record (NIS=2 > 1) and never advance into either
2370 /// isotope subsection.
2371 #[test]
2372 fn test_parse_endf_rejects_nis_gt_1() {
2373 // Minimal NIS=2 fixture. Both isotope subsections use LRF=1 SLBW so
2374 // that, if the NIS guard were ever removed, the parser would have a
2375 // valid stream to walk and the assertion below would still pin the
2376 // guard. The HEAD's ZA is set to the natural-element identifier
2377 // ZA=29000 (nat-Cu); for NIS=1 callers that would be rejected by
2378 // `isotope_from_za` with an "A=0" error, but the NIS=2 check runs
2379 // first and returns the expected UnsupportedFormat.
2380 const ENDF: &str = include_str!("../../../tests/data/synthetic/nis_gt_1_rejected.endf");
2381
2382 let err = parse_endf_file2(ENDF).unwrap_err();
2383 match &err {
2384 EndfParseError::UnsupportedFormat(msg) => {
2385 assert!(
2386 msg.contains("NIS=2"),
2387 "expected NIS=2 in error message, got: {msg}"
2388 );
2389 assert!(
2390 msg.contains("multi-isotope"),
2391 "expected 'multi-isotope' in error message, got: {msg}"
2392 );
2393 }
2394 other => panic!("expected UnsupportedFormat for NIS>1, got {other:?}"),
2395 }
2396 }
2397
2398 /// LRF=7 resonance LIST carries NRS in L2 and NX (packed-row count) in N2.
2399 ///
2400 /// ENDF-6 §2.2.1.6: the resonance LIST control record is
2401 /// `[C1=0, C2=0, L1=0, L2=NRS, N1=6*NX, N2=NX]`. For spin groups whose
2402 /// per-resonance row fits in one 6-float ENDF line (NCH+1 ≤ 6 for KRM=2,
2403 /// NCH+2 ≤ 6 for KRM=3), NX numerically equals NRS, so the field
2404 /// confusion is invisible. This fixture stresses the case where the
2405 /// per-resonance row requires *more than one* 6-float row, giving
2406 /// NX > NRS and L2 ≠ N2.
2407 ///
2408 /// Construction: KRM=3, NCH=5 (5 elastic channels in a single spin
2409 /// group), NRS=2.
2410 /// per-resonance values = NCH+2 = 7 → 2 packed rows of 6 floats (12
2411 /// values per resonance, last 5 are padding zeros).
2412 /// NX = NRS · 2 = 4 packed rows.
2413 /// NPL = 6·NX = 24 floats.
2414 /// resonance LIST control = `[0.0, 0.0, 0, NRS=2, 24, NX=4]`.
2415 ///
2416 /// Under the (pre-fix) buggy reader that took NRS from N2, this fixture
2417 /// would set NRS=4, recompute stride = NPL/NRS = 6, and trip the
2418 /// min_stride guard (6 < NCH+2 = 7) with a misleading
2419 /// "stride too small" UnsupportedFormat error. With the fix the skip reads
2420 /// NRS=2 (from L2), so stride = NPL/NRS = 24/2 = 12 ≥ NCH+2 = 7 and the
2421 /// range parses end-to-end (loading as a skipped RMatrixLimited range).
2422 #[test]
2423 fn test_parse_lrf7_l2_holds_nrs_with_nx_neq_nrs() {
2424 const ENDF: &str =
2425 include_str!("../../../tests/data/synthetic/lrf7_l2_holds_nrs_with_nx_neq_nrs.endf");
2426
2427 // Reading NRS from L2 keeps the stride guard satisfied, so the skip
2428 // consumes the wide-spin-group range cleanly and the parse reaches the
2429 // no-evaluable-ranges rejection (the LRF=7 range is the file's only
2430 // range) instead of the misleading "stride too small" error the
2431 // NRS-from-N2 misread would produce.
2432 assert_rejected_no_evaluable_ranges(ENDF);
2433 }
2434
2435 /// LRF=7 spin group with nonzero KBK (R-external background) is rejected.
2436 ///
2437 /// The ENDF-6 §2.2.1.6 manual prose treats KBK as a nonzero flag with NCH
2438 /// background records; OpenScale's reference reader
2439 /// (File2.cpp:444-524) treats KBK as a sparse record count with each
2440 /// subrecord carrying the channel index in L1 and the LBK formalism flag
2441 /// in L2. The two conventions disagree on loop bound, per-subrecord
2442 /// control-field positions, and payload shape per LBK value. No local
2443 /// ENDF/B-VIII.0 evaluation has nonzero KBK to validate against.
2444 /// Until a policy decision resolves the dispute, NEREIDS hard-rejects
2445 /// nonzero KBK so the parser cannot silently misalign the stream past
2446 /// the offending spin group.
2447 #[test]
2448 fn test_parse_lrf7_rejects_nonzero_kbk() {
2449 const ENDF: &str =
2450 include_str!("../../../tests/data/synthetic/lrf7_nonzero_kbk_rejected.endf");
2451
2452 let err = parse_endf_file2(ENDF).unwrap_err();
2453 match &err {
2454 EndfParseError::UnsupportedFormat(msg) => {
2455 assert!(
2456 msg.contains("KBK=1"),
2457 "expected KBK=1 in error message, got: {msg}"
2458 );
2459 }
2460 other => panic!("expected UnsupportedFormat for KBK != 0, got {other:?}"),
2461 }
2462 }
2463
2464 /// LRF=7 spin group with nonzero KPS (tabulated phase-shift override) is
2465 /// rejected. Same documentation-vs-implementation dispute as KBK;
2466 /// OpenScale itself refuses to read KPS > 0
2467 /// (File2.cpp:439-441 throws "kps > 0 for lrf=7 not yet supported"), so
2468 /// NEREIDS adopts the same behaviour rather than guess at a layout.
2469 #[test]
2470 fn test_parse_lrf7_rejects_nonzero_kps() {
2471 const ENDF: &str =
2472 include_str!("../../../tests/data/synthetic/lrf7_nonzero_kps_rejected.endf");
2473
2474 let err = parse_endf_file2(ENDF).unwrap_err();
2475 match &err {
2476 EndfParseError::UnsupportedFormat(msg) => {
2477 assert!(
2478 msg.contains("KPS=1"),
2479 "expected KPS=1 in error message, got: {msg}"
2480 );
2481 }
2482 other => panic!("expected UnsupportedFormat for KPS != 0, got {other:?}"),
2483 }
2484 }
2485
2486 /// LRF=7 particle pair with PNT=2 ("ASSIGN") is rejected at parse time.
2487 /// SAMMY's Check_Quantum (rml/mrml03.f:22) rejects Lpent outside {0,1};
2488 /// neither SAMMY nor NEREIDS implements PNT=2. Validating up front keeps
2489 /// the unknown penetrability flag out of the physics evaluator.
2490 #[test]
2491 fn test_parse_lrf7_rejects_pnt_two() {
2492 const ENDF: &str = include_str!("../../../tests/data/synthetic/lrf7_pnt2_rejected.endf");
2493
2494 let err = parse_endf_file2(ENDF).unwrap_err();
2495 match &err {
2496 EndfParseError::UnsupportedFormat(msg) => {
2497 assert!(
2498 msg.contains("PNT=2"),
2499 "expected PNT=2 in error message, got: {msg}"
2500 );
2501 }
2502 other => panic!("expected UnsupportedFormat for PNT=2, got {other:?}"),
2503 }
2504 }
2505
2506 /// LRF=7 massless particle pair (MA=0, photon/eliminated channel) declared
2507 /// with PNT=1 is rejected: SAMMY always assigns Lpent=0 to the photon
2508 /// channel, and the physics evaluator's PNT=1 penetrability branch would
2509 /// otherwise divide by a zero reduced mass.
2510 #[test]
2511 fn test_parse_lrf7_rejects_massless_pnt_one() {
2512 const ENDF: &str =
2513 include_str!("../../../tests/data/synthetic/lrf7_massless_pnt1_rejected.endf");
2514
2515 let err = parse_endf_file2(ENDF).unwrap_err();
2516 match &err {
2517 EndfParseError::UnsupportedFormat(msg) => {
2518 assert!(
2519 msg.contains("must have PNT=0"),
2520 "expected massless-PNT consistency message, got: {msg}"
2521 );
2522 }
2523 other => panic!("expected UnsupportedFormat for massless PNT=1, got {other:?}"),
2524 }
2525 }
2526
2527 /// LRF=7 particle pair with a fractional PNT (1.5) is rejected before the
2528 /// f64→i32 narrowing can truncate it into a spurious 0/1 that would bypass
2529 /// the {0,1} range check.
2530 #[test]
2531 fn test_parse_lrf7_rejects_fractional_pnt() {
2532 const ENDF: &str =
2533 include_str!("../../../tests/data/synthetic/lrf7_pnt_fractional_rejected.endf");
2534
2535 let err = parse_endf_file2(ENDF).unwrap_err();
2536 match &err {
2537 EndfParseError::UnsupportedFormat(msg) => {
2538 assert!(
2539 msg.contains("PNT=1.5") && msg.contains("not a finite integer"),
2540 "expected fractional-PNT message, got: {msg}"
2541 );
2542 }
2543 other => panic!("expected UnsupportedFormat for fractional PNT, got {other:?}"),
2544 }
2545 }
2546
2547 /// LRF=7 PNT=1 pair with a non-positive mass (MB=0) is rejected up front:
2548 /// the penetrability path would otherwise form a non-finite reduced mass.
2549 #[test]
2550 fn test_parse_lrf7_rejects_pnt1_zero_mass() {
2551 const ENDF: &str =
2552 include_str!("../../../tests/data/synthetic/lrf7_pnt1_zero_mass_rejected.endf");
2553
2554 let err = parse_endf_file2(ENDF).unwrap_err();
2555 match &err {
2556 EndfParseError::UnsupportedFormat(msg) => {
2557 assert!(
2558 msg.contains("finite positive masses"),
2559 "expected PNT=1 mass-validation message, got: {msg}"
2560 );
2561 }
2562 other => panic!("expected UnsupportedFormat for PNT=1 zero mass, got {other:?}"),
2563 }
2564 }
2565
2566 /// MF=2 NIS=0 (no isotopes declared) is rejected up-front.
2567 ///
2568 /// ENDF-6 §2.1 requires NIS >= 1 for a valid resonance evaluation.
2569 /// Without the explicit reject, NIS=0 would fall through the per-isotope
2570 /// loop (zero iterations), leave the resonance section empty, and trip a
2571 /// confusing downstream "unconsumed data lines" / empty-range failure
2572 /// far from the actual root cause. The reject mirrors the NIS>1 guard
2573 /// pattern so both invalid extremes return a clear UnsupportedFormat.
2574 #[test]
2575 fn test_parse_endf_rejects_nis_zero() {
2576 // Minimal NIS=0 fixture: just the HEAD line with NIS=0. The HEAD's
2577 // ZA=74184 (W-184) is a valid identifier, so any error must come
2578 // from the NIS=0 guard, not from `isotope_from_za`.
2579 // HEAD: ZA=74184, AWR=182, NIS=0
2580 const ENDF: &str = include_str!("../../../tests/data/synthetic/nis_zero_rejected.endf");
2581
2582 let err = parse_endf_file2(ENDF).unwrap_err();
2583 match &err {
2584 EndfParseError::UnsupportedFormat(msg) => {
2585 assert!(
2586 msg.contains("NIS=0"),
2587 "expected NIS=0 in error message, got: {msg}"
2588 );
2589 assert!(
2590 msg.contains("NIS >= 1"),
2591 "expected 'NIS >= 1' guidance in error message, got: {msg}"
2592 );
2593 }
2594 other => panic!("expected UnsupportedFormat for NIS=0, got {other:?}"),
2595 }
2596 }
2597
2598 /// LRF=7 resonance LIST with N2/NX not divisible by L2/NRS is rejected.
2599 ///
2600 /// ENDF-6 §2.2.1.6 fixes NX = NRS · ceil(stride/6) where stride is NCH+1
2601 /// for KRM=2 and NCH+2 for KRM=3, so NX must be an integer multiple of
2602 /// NRS (the per-resonance packed-row count is constant within a spin
2603 /// group). A fixture with NRS=4 and NX=2 yields a fractional stride
2604 /// 6·NX/NRS = 3 floats per resonance, which would mis-align the
2605 /// resonance reads. Without the divisibility check, the existing
2606 /// `res_npl == 6*nx` guard passes (12 == 6·2) and the downstream
2607 /// `res_npl % nrs != 0` would also pass (12 % 4 == 0), producing the
2608 /// bogus stride. The new guard catches this directly.
2609 #[test]
2610 fn test_parse_lrf7_rejects_nx_not_multiple_of_nrs() {
2611 const ENDF: &str =
2612 include_str!("../../../tests/data/synthetic/lrf7_nx_not_multiple_of_nrs_rejected.endf");
2613
2614 let err = parse_endf_file2(ENDF).unwrap_err();
2615 match &err {
2616 EndfParseError::UnsupportedFormat(msg) => {
2617 assert!(
2618 msg.contains("not a multiple"),
2619 "expected 'not a multiple' in error message, got: {msg}"
2620 );
2621 assert!(
2622 msg.contains("NX (2)") || msg.contains("(2)"),
2623 "expected NX=2 to appear in error message, got: {msg}"
2624 );
2625 assert!(
2626 msg.contains("NRS (4)") || msg.contains("(4)"),
2627 "expected NRS=4 to appear in error message, got: {msg}"
2628 );
2629 }
2630 other => {
2631 panic!("expected UnsupportedFormat for NX not multiple of NRS, got {other:?}")
2632 }
2633 }
2634 }
2635
2636 /// LRF=7 spin group with zero resonances must be accepted when written in
2637 /// the canonical ENDF-6 §2.2.1.6 form NRS=0, NX=1, NPL=6 (a single
2638 /// six-float zero-filler row in the LIST body).
2639 ///
2640 /// OpenScale's reference writer at
2641 /// `external/openScale/repo/packages/ScaleUtils/EndfLib/endf/File2.cpp:683-697`
2642 /// pads the resonance LIST for empty spin groups:
2643 ///
2644 /// ```cpp
2645 /// list.setL2(spin->getNres()); // L2 = NRS = 0
2646 /// ...
2647 /// // nx must be at least 1, even if nres=0
2648 /// if (spin->getNres() == 0)
2649 /// nx = 1;
2650 /// list.setN1(6 * nx); // N1 = 6
2651 /// list.setN2(nx); // N2 = 1
2652 /// ```
2653 ///
2654 /// A naive guard `if nrs == 0 && nx != 0 { reject }` would reject this
2655 /// canonical pattern (NX=1 ≠ 0). The relaxed guard
2656 /// `if nrs == 0 && nx != 1 { reject }` accepts it while still rejecting
2657 /// malformed shapes such as NRS=0/NX=2.
2658 #[test]
2659 fn test_parse_lrf7_nrs_zero_nx_one_canonical_empty_passes_guard() {
2660 const ENDF: &str =
2661 include_str!("../../../tests/data/synthetic/lrf7_nrs_zero_nx_one_canonical_empty.endf");
2662
2663 // The canonical empty spin group (NRS=0/NX=1) passes the relaxed
2664 // guard, so the skip consumes the range cleanly and the parse reaches
2665 // the no-evaluable-ranges rejection (the LRF=7 range is the file's
2666 // only range) rather than the NRS=0/NX guard error a stricter check
2667 // would raise.
2668 assert_rejected_no_evaluable_ranges(ENDF);
2669 }
2670
2671 /// LRF=7 spin group with NRS=0 but NX≠1 is rejected as malformed.
2672 ///
2673 /// OpenScale's writer (File2.cpp:683-697) explicitly pads NX to 1 when
2674 /// NRS=0, so any NRS=0 record with NX=0 (no filler row) or NX>1 (phantom
2675 /// filler rows with nothing to anchor them) is not a valid ENDF-6 emission.
2676 /// The previous over-permissive guard accepted NRS=0/NX=2 silently,
2677 /// leaving the parser to read two zero-filled rows as "no resonances"
2678 /// while the LIST body did contain data that some other reader might
2679 /// interpret as resonance parameters.
2680 #[test]
2681 fn test_parse_lrf7_rejects_nrs_zero_nx_two_malformed() {
2682 const ENDF: &str =
2683 include_str!("../../../tests/data/synthetic/lrf7_nrs_zero_nx_two_malformed.endf");
2684
2685 let err = parse_endf_file2(ENDF).unwrap_err();
2686 match &err {
2687 EndfParseError::UnsupportedFormat(msg) => {
2688 assert!(
2689 msg.contains("NRS=0"),
2690 "expected 'NRS=0' in error message, got: {msg}"
2691 );
2692 assert!(
2693 msg.contains("NX=2"),
2694 "expected 'NX=2' in error message, got: {msg}"
2695 );
2696 }
2697 other => panic!("expected UnsupportedFormat for NRS=0/NX!=1, got {other:?}"),
2698 }
2699 }
2700}