Skip to main content

nereids_endf/
sammy.rs

1//! SAMMY test-suite file parsers.
2//!
3//! Parses SAMMY `.par` (resonance parameters), `.inp` (input configuration),
4//! and `.plt` (plot reference output) files from the `samtry/` test suite.
5//! These parsers are intentionally minimal — they cover the subset of SAMMY's
6//! format needed for Phase 1 transmission validation (issue #292).
7//!
8//! ## SAMMY Reference
9//! - SAMMY Manual, Section 2 (input file format)
10//! - SAMMY Manual, Section 8 (parameter file format)
11//! - `samtry/tr007/`, `samtry/tr004/`, etc. for concrete examples
12
13use std::fmt;
14
15use nereids_core::types::Isotope;
16
17use crate::resonance::{
18    LGroup, RExternalEntry, Resonance, ResonanceData, ResonanceFormalism, ResonanceRange,
19};
20
21// ─── Error type ────────────────────────────────────────────────────────────────
22
23/// Error from parsing a SAMMY file.
24#[derive(Debug)]
25pub struct SammyParseError {
26    pub message: String,
27}
28
29impl fmt::Display for SammyParseError {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        write!(f, "SAMMY parse error: {}", self.message)
32    }
33}
34
35impl std::error::Error for SammyParseError {}
36
37impl SammyParseError {
38    fn new(msg: impl Into<String>) -> Self {
39        Self {
40            message: msg.into(),
41        }
42    }
43}
44
45// ─── .par file types ───────────────────────────────────────────────────────────
46
47/// A single resonance from a SAMMY `.par` file.
48#[derive(Debug, Clone)]
49pub struct SammyResonance {
50    /// Resonance energy (eV).
51    pub energy_ev: f64,
52    /// Radiation (gamma) width Γ_γ (eV). Converted from meV in file.
53    pub gamma_gamma_ev: f64,
54    /// Neutron width Γ_n (eV). Converted from meV in file.
55    pub gamma_n_ev: f64,
56    /// First fission width (eV). Zero for non-fissile isotopes.
57    pub gamma_f1_ev: f64,
58    /// Second fission width (eV). Zero for non-fissile isotopes.
59    pub gamma_f2_ev: f64,
60    /// Spin group index (1-based, references `.inp` Card Set 10).
61    pub spin_group: u32,
62}
63
64/// Per-isotope mass and abundance entry from "ISOTOPIC MASSES AND ABUNDANCES
65/// FOLLOW" section in a SAMMY `.par` file.
66///
67/// SAMMY Ref: `IsotopicMassesAndAbundances.cpp`, Manual Section II.B.1.e
68#[derive(Debug, Clone)]
69pub struct IsotopicMass {
70    /// Atomic weight ratio (target mass / neutron mass) for this isotope.
71    pub awr: f64,
72    /// Fractional abundance (number fraction) of this isotope in the sample.
73    pub abundance: f64,
74    /// 1-based spin group indices assigned to this isotope.
75    pub spin_groups: Vec<u32>,
76}
77
78/// Parsed SAMMY `.par` file.
79#[derive(Debug, Clone)]
80pub struct SammyParFile {
81    pub resonances: Vec<SammyResonance>,
82    /// Per-spin-group channel radius overrides from "RADIUS PARAMETERS FOLLOW".
83    /// Maps 1-based spin group index → effective channel radius in fm.
84    /// When present, overrides the global scattering radius from the .inp file.
85    pub radius_overrides: std::collections::HashMap<u32, f64>,
86    /// Per-spin-group R-external parameters from "R-EXTERNAL PARAMETERS FOLLOW".
87    /// Maps 1-based spin group index → 7 R-external parameters
88    /// [E_low, E_up, R_con, R_lin, s_con, s_lin, R_quad].
89    ///
90    /// SAMMY Ref: mpar03.f90 Readrx, Manual Section II.B.1.d
91    pub r_external: std::collections::HashMap<u32, [f64; 7]>,
92    /// Per-isotope AWR and abundance from "ISOTOPIC MASSES AND ABUNDANCES FOLLOW".
93    /// When present, overrides .inp spin group abundances and provides per-isotope
94    /// AWR values (the .inp only has a global AWR from Card 2).
95    ///
96    /// SAMMY Ref: `IsotopicMassesAndAbundances.cpp`
97    pub isotopic_masses: Vec<IsotopicMass>,
98}
99
100// ─── .inp file types ───────────────────────────────────────────────────────────
101
102/// Spin group definition from `.inp` Card Set 10.
103#[derive(Debug, Clone)]
104pub struct SammySpinGroup {
105    /// 1-based group index.
106    pub index: u32,
107    /// Total angular momentum J.  May be negative per SAMMY sign convention
108    /// (negative J distinguishes spin groups with the same |J|).
109    pub j: f64,
110    /// Orbital angular momentum L (from the channel line).
111    pub l: u32,
112    /// Statistical weight / abundance.
113    pub abundance: f64,
114    /// Per-spin-group target spin I, parsed from columns \[30:35\] of each
115    /// header line.  Defaults to 0.0 (even-even nuclei).
116    pub target_spin: f64,
117    /// Optional isotope label from the header line (columns ~52+),
118    /// e.g. "Cu65", "Cu63".  Present in multi-isotope SAMMY cases.
119    pub isotope_label: Option<String>,
120}
121
122/// Observation type parsed from the SAMMY `.inp` keyword block.
123///
124/// Determines what physical quantity SAMMY's Th_initial column represents:
125/// - Transmission/TotalCrossSection → σ_total
126/// - Fission → σ_fission
127#[derive(Debug, Clone, Copy, PartialEq, Default)]
128pub enum SammyObservationType {
129    #[default]
130    Transmission,
131    TotalCrossSection,
132    Fission,
133}
134
135/// Beamline and sample configuration from a SAMMY `.inp` file.
136#[derive(Debug, Clone)]
137pub struct SammyInpConfig {
138    pub title: String,
139    /// Isotope symbol as written in the file (e.g. "60NI", "FE56").
140    pub isotope_symbol: String,
141    /// Atomic weight ratio (target mass / neutron mass).
142    pub awr: f64,
143    /// Lower energy bound for resonance range (eV).
144    pub energy_min_ev: f64,
145    /// Upper energy bound for resonance range (eV).
146    pub energy_max_ev: f64,
147    /// Sample temperature (K).
148    pub temperature_k: f64,
149    /// Flight path length (m).
150    pub flight_path_m: f64,
151    /// Card 5, field 3: Deltal — flight path uncertainty (SAMMY units).
152    ///
153    /// Maps to rslRes parameter 1 → Bo2 in SAMMY's resolution broadening.
154    /// SAMMY Ref: `minp06.f90` line 226.
155    pub delta_l_sammy: f64,
156    /// Card 5, field 4: Deltae — exponential tail parameter (SAMMY units).
157    ///
158    /// Maps to rslRes parameter 3 → Co2 in SAMMY's resolution broadening.
159    /// When zero, no exponential broadening is applied (Iesopr=1, pure Gaussian).
160    pub delta_e_sammy: f64,
161    /// Card 5, field 5: Deltag — timing uncertainty (SAMMY units).
162    ///
163    /// Maps to rslRes parameter 2 → Ao2 in SAMMY's resolution broadening.
164    pub delta_g_sammy: f64,
165    /// When true, broadening is explicitly disabled for this case
166    /// (e.g., `BROADENING IS NOT WANTED` or `NO LOW-ENERGY BROADENING`).
167    pub no_broadening: bool,
168    /// BROADENING card override for Deltal (rslRes param 1).
169    /// Non-zero values override Card 5 `delta_l_sammy`.
170    /// SAMMY Ref: `minp18.f90` lines 89-94.
171    pub broadening_delta_l: Option<f64>,
172    /// BROADENING card override for Deltag (rslRes param 2).
173    pub broadening_delta_g: Option<f64>,
174    /// BROADENING card override for Deltae (rslRes param 3).
175    pub broadening_delta_e: Option<f64>,
176    /// Scattering radius (fm).
177    pub scattering_radius_fm: f64,
178    /// Sample thickness (atoms/barn).
179    pub thickness_atoms_barn: f64,
180    /// Target nuclear spin I.  Determines the statistical weight
181    /// g_J = (2J+1) / ((2I+1)(2s+1)).  Parsed from field 6 of the
182    /// spin group header in Card Set 10 (all headers should agree).
183    /// Defaults to 0.0 (even-even nuclei like Fe-56, Ni-58, Ni-60).
184    pub target_spin: f64,
185    /// Spin group definitions.
186    pub spin_groups: Vec<SammySpinGroup>,
187    /// Observation type (Transmission, TotalCrossSection, or Fission).
188    pub observation_type: SammyObservationType,
189}
190
191impl SammyInpConfig {
192    /// Effective Deltal: BROADENING card override if non-zero, else Card 5.
193    #[must_use]
194    pub fn effective_delta_l(&self) -> f64 {
195        self.broadening_delta_l
196            .filter(|&v| v != 0.0)
197            .unwrap_or(self.delta_l_sammy)
198    }
199
200    /// Effective Deltag: BROADENING card override if non-zero, else Card 5.
201    #[must_use]
202    pub fn effective_delta_g(&self) -> f64 {
203        self.broadening_delta_g
204            .filter(|&v| v != 0.0)
205            .unwrap_or(self.delta_g_sammy)
206    }
207
208    /// Effective Deltae: BROADENING card override if non-zero, else Card 5.
209    #[must_use]
210    pub fn effective_delta_e(&self) -> f64 {
211        self.broadening_delta_e
212            .filter(|&v| v != 0.0)
213            .unwrap_or(self.delta_e_sammy)
214    }
215}
216
217/// Convert SAMMY resolution parameters to NEREIDS-convention values.
218///
219/// SAMMY stores resolution parameters in a different convention from NEREIDS:
220///
221/// | SAMMY coefficient | Formula | NEREIDS equivalent |
222/// |---|---|---|
223/// | Ao2 = (1.20112·Deltag / (Sm2·Dist))² | timing | `delta_t = Deltag / (2·√ln2)` |
224/// | Bo2 = (0.81650·Deltal / Dist)² | path | `delta_l = Deltal / √6` |
225///
226/// where 1.20112 = 1/√ln2, 0.81650 = √(2/3), Sm2 = TOF_FACTOR = 72.298.
227///
228/// SAMMY Ref: `RslResolutionFunction_M.f90` (getAo2 lines 143-161, getBo2 lines 165-179)
229///
230/// Returns `None` if all effective Deltal, Deltag, and Deltae are zero
231/// (no resolution broadening).
232/// Otherwise returns `Some((flight_path_m, delta_t_us, delta_l_m, delta_e))`.
233///
234/// The fourth element `delta_e` is the exponential tail parameter (raw SAMMY
235/// Deltae units, passed through without conversion). When non-zero, the
236/// resolution kernel is the convolution of a Gaussian with an exponential
237/// tail (SAMMY Iesopr=3).
238#[must_use]
239pub fn sammy_to_nereids_resolution(inp: &SammyInpConfig) -> Option<(f64, f64, f64, f64)> {
240    if inp.no_broadening {
241        return None;
242    }
243
244    let delta_l = inp.effective_delta_l();
245    let delta_g = inp.effective_delta_g();
246    let delta_e = inp.effective_delta_e();
247
248    if delta_l == 0.0 && delta_g == 0.0 && delta_e == 0.0 {
249        return None;
250    }
251
252    // Convert from SAMMY convention to NEREIDS convention.
253    let delta_t_us = delta_g / (2.0 * 2.0_f64.ln().sqrt());
254    let delta_l_m = delta_l / 6.0_f64.sqrt();
255    // delta_e: no conversion needed — raw SAMMY Deltae maps directly to
256    // NEREIDS's exp_width formula: Widexp = 2·Deltae·E^(3/2)/(TOF_FACTOR·L).
257    // SAMMY Ref: RslResolutionFunction_M.f90 getCo2, mrsl4.f90 Wdsint.
258
259    Some((inp.flight_path_m, delta_t_us, delta_l_m, delta_e))
260}
261
262// ─── .plt file types ───────────────────────────────────────────────────────────
263
264/// A single record from a SAMMY `.plt` reference output file.
265#[derive(Debug, Clone)]
266pub struct SammyPltRecord {
267    /// Energy (keV). SAMMY .plt files use keV, not eV.
268    pub energy_kev: f64,
269    /// Experimental data (cross-section in barns, or transmission).
270    pub data: f64,
271    /// Uncertainty on the data.
272    pub uncertainty: f64,
273    /// Theoretical value before fitting (our comparison target).
274    pub theory_initial: f64,
275    /// Theoretical value after fitting.
276    pub theory_final: f64,
277}
278
279// ─── .plt parser ───────────────────────────────────────────────────────────────
280
281/// Parse a SAMMY `.plt` file (5-column reference output with header).
282///
283/// Format:
284/// ```text
285///       Energy            Data      Uncertainty     Th_initial      Th_final
286///    1.1330168       8.9078373      0.32005507       8.4964191       8.5014004
287/// ```
288pub fn parse_sammy_plt(content: &str) -> Result<Vec<SammyPltRecord>, SammyParseError> {
289    let mut records = Vec::new();
290    for (i, line) in content.lines().enumerate() {
291        let trimmed = line.trim();
292        if trimmed.is_empty() {
293            continue;
294        }
295        // Skip header line (contains non-numeric text).
296        if trimmed.starts_with("Energy") || trimmed.contains("Th_initial") {
297            continue;
298        }
299        let fields: Vec<&str> = trimmed.split_whitespace().collect();
300        if fields.len() < 4 {
301            return Err(SammyParseError::new(format!(
302                "line {}: expected >=4 columns, got {}",
303                i + 1,
304                fields.len()
305            )));
306        }
307        let parse = |s: &str, col: &str| {
308            s.parse::<f64>().map_err(|e| {
309                SammyParseError::new(format!("line {}: cannot parse {col}: {e}", i + 1))
310            })
311        };
312        // Some .plt files have only 4 columns (no Th_final) when SAMMY
313        // did not fit (e.g., "reconstruct cross sections" mode).
314        records.push(SammyPltRecord {
315            energy_kev: parse(fields[0], "energy")?,
316            data: parse(fields[1], "data")?,
317            uncertainty: parse(fields[2], "uncertainty")?,
318            theory_initial: parse(fields[3], "theory_initial")?,
319            theory_final: if fields.len() >= 5 {
320                parse(fields[4], "theory_final")?
321            } else {
322                0.0
323            },
324        });
325    }
326    if records.is_empty() {
327        return Err(SammyParseError::new("no data records found in .plt file"));
328    }
329    Ok(records)
330}
331
332// ─── .par parser ───────────────────────────────────────────────────────────────
333
334/// Parse a Fortran-style floating-point literal.
335///
336/// Handles compact exponent notation without 'E': `5.400000-5` → `5.4e-5`,
337/// `1.23+3` → `1.23e3`. Also handles Fortran D-exponent: `1.23D-5` → `1.23e-5`.
338/// Standard notation (`1.23E-5`, `1.23`) is also handled.
339fn parse_fortran_float(s: &str) -> Result<f64, std::num::ParseFloatError> {
340    // Try standard parse first (fast path).
341    if let Ok(v) = s.parse::<f64>() {
342        return Ok(v);
343    }
344    // Handle Fortran D-exponent: "1.23D-5" or "1.23d+3" → replace D/d with E.
345    if let Some(pos) = s.find(['D', 'd']) {
346        let mut fixed = String::with_capacity(s.len());
347        fixed.push_str(&s[..pos]);
348        fixed.push('E');
349        fixed.push_str(&s[pos + 1..]);
350        return fixed.parse::<f64>();
351    }
352    // Look for a bare +/- exponent after a digit: "1.23-5" or "1.23+3".
353    // Skip the first character to avoid matching a leading sign.
354    if s.len() > 1 {
355        for (i, c) in s[1..].char_indices() {
356            let pos = i + 1; // position in original string
357            if (c == '+' || c == '-') && s.as_bytes()[pos - 1].is_ascii_digit() {
358                let mut fixed = String::with_capacity(s.len() + 1);
359                fixed.push_str(&s[..pos]);
360                fixed.push('E');
361                fixed.push_str(&s[pos..]);
362                return fixed.parse::<f64>();
363            }
364        }
365    }
366    s.parse::<f64>()
367}
368
369/// Parse a SAMMY `.par` file (resonance parameters).
370///
371/// Uses Fortran fixed-width column parsing (FORMAT 5F11.4, 5I2, I2):
372///   cols  0-10: E_res (eV)         — 11 chars
373///   cols 11-21: Γ_γ (meV)          — 11 chars
374///   cols 22-32: Γ_n (meV)          — 11 chars
375///   cols 33-43: Γ_f1 (meV)         — 11 chars
376///   cols 44-54: Γ_f2 (meV)         — 11 chars
377///   cols 55-64: vary flags (5×I2)  — 10 chars
378///   cols 65-66: spin_group_id (I2) — 2 chars
379///
380/// Widths are in meV in the file; this function converts to eV.
381///
382/// SAMMY Ref: `ResonanceParameterIO.cpp`, `mrpti.f90`.
383pub fn parse_sammy_par(content: &str) -> Result<SammyParFile, SammyParseError> {
384    let mut resonances = Vec::new();
385
386    for (i, line) in content.lines().enumerate() {
387        let trimmed = line.trim();
388        // Stop at blank line or EXPLICIT keyword.
389        if trimmed.is_empty() || trimmed.starts_with("EXPLICIT") {
390            break;
391        }
392
393        // Need at least enough characters for E + Γ_γ + Γ_n (33 chars).
394        if line.len() < 33 {
395            return Err(SammyParseError::new(format!(
396                "line {}: too short for .par format (need ≥33 chars, got {})",
397                i + 1,
398                line.len()
399            )));
400        }
401
402        let parse_col = |start: usize, end: usize, name: &str| -> Result<f64, SammyParseError> {
403            let s = if start >= line.len() {
404                // Column starts past line end — treat missing as zero.
405                ""
406            } else {
407                line[start..end.min(line.len())].trim()
408            };
409            if s.is_empty() {
410                return Ok(0.0);
411            }
412            parse_fortran_float(s).map_err(|e| {
413                SammyParseError::new(format!("line {}: cannot parse {name} ({s:?}): {e}", i + 1))
414            })
415        };
416
417        let energy_ev = parse_col(0, 11, "E_res")?;
418        let gamma_gamma_mev = parse_col(11, 22, "Γ_γ")?;
419        let gamma_n_mev = parse_col(22, 33, "Γ_n")?;
420        let gamma_f1_mev = parse_col(33, 44, "Γ_f1")?;
421        let gamma_f2_mev = parse_col(44, 55, "Γ_f2")?;
422
423        // Spin group index at cols 65-66 (I2).
424        // The standard .par resonance line is FORMAT(5E11.4, 5I2, I2) = 67 columns.
425        // Optional trailing data (e.g., energy uncertainties) starts at position 67.
426        //
427        // SAMMY convention (ResonanceParameterIO.cpp:217-220): negative spin group
428        // means "exclude this resonance from the calculation" (setIncludeInCalc(false)).
429        // We skip excluded resonances entirely.
430        let spin_group_signed: i32 = if line.len() > 65 {
431            let end = line.len().min(67);
432            let sg_str = line[65..end].trim();
433            if sg_str.is_empty() {
434                1
435            } else {
436                sg_str.parse::<i32>().map_err(|e| {
437                    SammyParseError::new(format!(
438                        "line {}: cannot parse spin group ({sg_str:?}): {e}",
439                        i + 1
440                    ))
441                })?
442            }
443        } else {
444            1 // Default spin group if line is too short.
445        };
446
447        if spin_group_signed < 0 {
448            // Negative spin group → excluded from calculation.
449            continue;
450        }
451        let spin_group = spin_group_signed as u32;
452
453        resonances.push(SammyResonance {
454            energy_ev,
455            gamma_gamma_ev: gamma_gamma_mev / 1000.0,
456            gamma_n_ev: gamma_n_mev / 1000.0,
457            gamma_f1_ev: gamma_f1_mev / 1000.0,
458            gamma_f2_ev: gamma_f2_mev / 1000.0,
459            spin_group,
460        });
461    }
462
463    if resonances.is_empty() {
464        return Err(SammyParseError::new("no resonances found in .par file"));
465    }
466
467    // Continue scanning for "RADIUS PARAMETERS FOLLOW" section after the
468    // resonance block.  SAMMY par file layout:
469    //   <resonances>
470    //   <blank>
471    //   <uncertainties/other sections>
472    //   RADIUS PARAMETERS FOLLOW
473    //   <radius lines>
474    //   <blank>
475    //
476    // Ref: SAMMY AdjustedRadiusData.cpp readOldStyle
477    let mut radius_overrides = std::collections::HashMap::new();
478    let lines_vec: Vec<&str> = content.lines().collect();
479    if let Some(rad_idx) = lines_vec
480        .iter()
481        .position(|l| l.trim().to_uppercase().starts_with("RADIUS"))
482    {
483        for rad_line in &lines_vec[rad_idx + 1..] {
484            let trimmed = rad_line.trim();
485            if trimmed.is_empty() || trimmed.starts_with(|c: char| c.is_alphabetic()) {
486                break; // End of radius section.
487            }
488
489            // Old-style format (AdjustedRadiusData.cpp readOldStyle):
490            //   Positions 0-9:   effective radius (F10)
491            //   Positions 10-19: true radius (F10)
492            //   Position  20:    chanOpt (I1) — ignored
493            //   Position  21:    ifleff  (I1) — ignored
494            //   Positions 22-23: ifltrue (I2) — ignored
495            //   Positions 24+:   spin group indices (I2 each, 0 = terminator)
496            if rad_line.len() < 20 {
497                continue;
498            }
499            let r_eff: f64 = rad_line[..10].trim().parse().unwrap_or(0.0);
500            if r_eff <= 0.0 {
501                continue;
502            }
503
504            // Extract spin group indices from I2 fields starting at position 24.
505            let group_str = if rad_line.len() > 24 {
506                &rad_line[24..]
507            } else {
508                ""
509            };
510            let mut pos = 0;
511            let mut found_group = false;
512            while pos + 2 <= group_str.len() {
513                let field = group_str[pos..pos + 2].trim();
514                pos += 2;
515                let val: i32 = match field.parse() {
516                    Ok(v) => v,
517                    Err(_) => continue,
518                };
519                if val > 0 {
520                    radius_overrides.insert(val as u32, r_eff);
521                    found_group = true;
522                } else if val == 0 && found_group {
523                    break; // 0 after groups = terminator.
524                }
525            }
526
527            // Fallback: if no I2 groups found but line has tokens after radii,
528            // try whitespace-split parsing (handles short lines like "3.57 3.57 0 0 6").
529            if !found_group {
530                for tok in rad_line[20..].split_whitespace() {
531                    if let Ok(v) = tok.parse::<i32>()
532                        && v > 0
533                    {
534                        radius_overrides.insert(v as u32, r_eff);
535                    }
536                }
537            }
538        }
539    }
540
541    // Scan for "R-EXTERNAL PARAMETERS FOLLOW" section.
542    //
543    // SAMMY 7-parameter format (mpar03.f90 Readrx):
544    //   I2 = spin group number (cols 1-2)
545    //   I1 = channel number    (col  3)
546    //   7×I1 = fit flags       (cols 4-10)
547    //   7×F10.1 = parameters   (cols 11-80)
548    //     P[0]=E_low, P[1]=E_up, P[2]=R_con, P[3]=R_lin,
549    //     P[4]=s_con, P[5]=s_lin, P[6]=R_quad
550    //
551    // Ref: SAMMY Manual Section II.B.1.d, mpar03.f90 Readrx
552    let mut r_external = std::collections::HashMap::new();
553    if let Some(rext_idx) = lines_vec
554        .iter()
555        .position(|l| l.trim().starts_with("R-EXTERNAL PARAMETERS"))
556    {
557        for rext_line in &lines_vec[rext_idx + 1..] {
558            let trimmed = rext_line.trim();
559            if trimmed.is_empty() || trimmed.starts_with(|c: char| c.is_alphabetic()) {
560                break; // End of R-external section.
561            }
562
563            // Need at least 10 chars (I2+I1+7×I1) + 70 chars (7×F10.1) = 80.
564            if rext_line.len() < 40 {
565                continue;
566            }
567
568            // Parse spin group index (I2, cols 0-1) and channel (I1, col 2).
569            let sg: u32 = match rext_line[..2].trim().parse() {
570                Ok(v) if v > 0 => v,
571                _ => continue,
572            };
573            // Channel number (col 2) — we only use channel 1 (neutron elastic).
574            // Fit flags (cols 3-9) — not needed for cross-section calculation.
575
576            // Parse 7 parameters from cols 10+, each F10.1 (10 chars wide).
577            // Allow flexible parsing: try fixed-width first, fall back to
578            // whitespace-split for non-standard formatting.
579            let param_str = &rext_line[10..];
580            let mut params = [0.0f64; 7];
581            let mut parsed_ok = true;
582
583            // Try fixed-width F10.1 parsing (standard SAMMY format).
584            if param_str.len() >= 70 {
585                for (i, chunk) in (0..7).map(|i| (i, &param_str[i * 10..(i + 1) * 10])) {
586                    match chunk.trim().parse::<f64>() {
587                        Ok(v) => params[i] = v,
588                        Err(_) => {
589                            parsed_ok = false;
590                            break;
591                        }
592                    }
593                }
594            } else {
595                parsed_ok = false;
596            }
597
598            // Fallback: whitespace-delimited parsing.
599            if !parsed_ok {
600                let tokens: Vec<f64> = param_str
601                    .split_whitespace()
602                    .filter_map(|t| t.parse().ok())
603                    .collect();
604                // Require exactly 7 parameters; skip malformed entries.
605                if tokens.len() != 7 {
606                    continue;
607                }
608                for (i, &v) in tokens.iter().enumerate().take(7) {
609                    params[i] = v;
610                }
611            }
612
613            // Keep the FIRST entry per spin group (channel 1 = elastic).
614            // Subsequent channels (fission, etc.) are intentionally ignored
615            // since only the elastic R-external contribution is currently used.
616            r_external.entry(sg).or_insert(params);
617        }
618    }
619
620    // Scan for "ISOTOPIC MASSES AND ABUNDANCES FOLLOW" section.
621    //
622    // Format per line (fixed-width, same I2 spin group convention as RADIUS):
623    //   cols  0- 9: AWR (F10)
624    //   cols 10-19: abundance (F10)
625    //   cols 20-29: uncertainty on abundance (F10, ignored)
626    //   cols 30-31: flag (I2, ignored)
627    //   cols 32+ : spin group indices (I2 each, 0 = terminator)
628    //
629    // SAMMY Ref: IsotopicMassesAndAbundances.cpp
630    let mut isotopic_masses = Vec::new();
631    if let Some(iso_idx) = lines_vec
632        .iter()
633        .position(|l| l.trim().to_uppercase().starts_with("ISOTOPIC"))
634    {
635        for iso_line in &lines_vec[iso_idx + 1..] {
636            let trimmed = iso_line.trim();
637            if trimmed.is_empty() || trimmed.starts_with(|c: char| c.is_alphabetic()) {
638                break;
639            }
640            if iso_line.len() < 20 {
641                continue;
642            }
643
644            let awr: f64 = iso_line[..10].trim().parse().map_err(|_| {
645                SammyParseError::new(format!(
646                    "ISOTOPIC MASSES: bad AWR field '{}'",
647                    iso_line[..10].trim()
648                ))
649            })?;
650            let abundance: f64 = iso_line[10..20].trim().parse().map_err(|_| {
651                SammyParseError::new(format!(
652                    "ISOTOPIC MASSES: bad abundance field '{}'",
653                    iso_line[10..20].trim()
654                ))
655            })?;
656            if awr <= 0.0 {
657                continue;
658            }
659
660            // Parse spin group indices — I2 fields starting at position 32,
661            // same convention as RADIUS PARAMETERS section.
662            let group_start = 32.min(iso_line.len());
663            let group_str = &iso_line[group_start..];
664            let mut spin_groups = Vec::new();
665            let mut pos = 0;
666            let mut found_group = false;
667            while pos + 2 <= group_str.len() {
668                let field = group_str[pos..pos + 2].trim();
669                pos += 2;
670                let val: i32 = match field.parse() {
671                    Ok(v) => v,
672                    Err(_) => continue,
673                };
674                if val > 0 {
675                    spin_groups.push(val as u32);
676                    found_group = true;
677                } else if val == 0 && found_group {
678                    break;
679                }
680            }
681
682            // Fallback: whitespace-split parsing for non-standard formatting.
683            if !found_group {
684                for tok in iso_line[group_start..].split_whitespace() {
685                    if let Ok(v) = tok.parse::<i32>()
686                        && v > 0
687                    {
688                        spin_groups.push(v as u32);
689                    }
690                }
691            }
692
693            if !spin_groups.is_empty() {
694                isotopic_masses.push(IsotopicMass {
695                    awr,
696                    abundance,
697                    spin_groups,
698                });
699            }
700        }
701    }
702
703    Ok(SammyParFile {
704        resonances,
705        radius_overrides,
706        r_external,
707        isotopic_masses,
708    })
709}
710
711// ─── .inp parser ───────────────────────────────────────────────────────────────
712
713/// Parse a SAMMY `.inp` file (minimal, for Phase 1 transmission cases).
714///
715/// Extracts: isotope info (Card 2), broadening params (Card 5), sample params
716/// (Card 6), data type keyword, and spin group definitions (Card 10).
717pub fn parse_sammy_inp(content: &str) -> Result<SammyInpConfig, SammyParseError> {
718    let lines: Vec<&str> = content.lines().collect();
719    if lines.len() < 2 {
720        return Err(SammyParseError::new(
721            "file too short (need at least 2 lines)",
722        ));
723    }
724
725    // Line 1: title.
726    let title = lines[0].trim().to_string();
727
728    // Line 2: isotope definition (Fortran FORMAT A10, F10.5, 2F10.1, I5, I5).
729    //
730    // Fixed-width columns:
731    //   [0:10]  isotope symbol (may contain spaces, e.g. "CU 65")
732    //   [10:20] AWR (atomic weight ratio)
733    //   [20:30] Emin (lower energy bound, eV)
734    //   [30:40] Emax (upper energy bound, eV)
735    //
736    // SAMMY Ref: `minp01.f90` Card Set 2 format.
737    let iso_line = lines[1];
738    if iso_line.len() < 30 {
739        return Err(SammyParseError::new(format!(
740            "line 2: too short for Card 2 fixed-width format (need ≥30 chars, got {})",
741            iso_line.len()
742        )));
743    }
744    let isotope_symbol = iso_line[..10.min(iso_line.len())].trim().to_string();
745    let awr = iso_line[10..20.min(iso_line.len())]
746        .trim()
747        .parse::<f64>()
748        .map_err(|e| SammyParseError::new(format!("line 2: AWR: {e}")))?;
749    let energy_min_ev = iso_line[20..30.min(iso_line.len())]
750        .trim()
751        .parse::<f64>()
752        .map_err(|e| SammyParseError::new(format!("line 2: Emin: {e}")))?;
753    let energy_max_ev = if iso_line.len() > 30 {
754        iso_line[30..40.min(iso_line.len())]
755            .trim()
756            .parse::<f64>()
757            .map_err(|e| SammyParseError::new(format!("line 2: Emax: {e}")))?
758    } else {
759        // Some files may have a short line — fall back to Emin.
760        energy_min_ev
761    };
762
763    // Scan for keyword commands (skip until blank line), then parse Card 5, 6.
764    // Also look for TRANSMISSION keyword and spin group block.
765    let mut temperature_k = 300.0;
766    let mut flight_path_m = 0.0;
767    let mut delta_l_sammy = 0.0;
768    let mut delta_e_sammy = 0.0;
769    let mut delta_g_sammy = 0.0;
770    let mut scattering_radius_fm = 0.0;
771    let mut thickness_atoms_barn = 0.0;
772    let mut target_spin = 0.0;
773    let mut spin_groups = Vec::new();
774    let mut no_broadening = false;
775
776    // State machine: find blank line after commands, then parse numeric cards.
777    let mut idx = 2;
778    // Scan command lines until first blank line, detecting special keywords.
779    while idx < lines.len() {
780        let trimmed = lines[idx].trim();
781        if trimmed.is_empty() {
782            idx += 1;
783            break;
784        }
785        // Detect no-broadening keywords.
786        let upper = trimmed.to_uppercase();
787        // SAMMY allows abbreviated keywords: "BROADENING IS NOT WA" matches.
788        if upper.starts_with("BROADENING IS NOT") || upper.contains("NO LOW-ENERGY BROADENING") {
789            no_broadening = true;
790        }
791        // "INPUT IS ENDF/B FILE" and similar keywords are informational only.
792        // NEREIDS uses its own ENDF parser; such keywords (and any optional
793        // Mat= parameter on the same line) are ignored here.
794        idx += 1;
795    }
796
797    // Card 5: broadening parameters (first numeric line after blank).
798    //
799    // SAMMY format (minp06.f90 line 226):
800    //   READ (Iu22,10100) Temp, Dist, Deltal, Deltae, Deltag, ...
801    //
802    // - Temp: sample temperature (K)
803    // - Dist: flight path length (m)
804    // - Deltal: flight path uncertainty → rslRes param 1 → Bo2
805    // - Deltae: exponential tail → rslRes param 3 → Co2
806    // - Deltag: timing uncertainty → rslRes param 2 → Ao2
807    //
808    // When `no_broadening` is set AND the first numeric line has exactly 2
809    // fields, Card 5 is absent — the line is Card 6 (scattering_radius,
810    // thickness).  Example: tr034c.inp.
811    // Card 5 (broadening params) is always present when broadening is active.
812    // When "BROADENING IS NOT WANTED", Card 5 may or may not be present:
813    // - tr028: no_broadening + Card 5 present (7 fields, next line is Card 6)
814    // - tr018/tr034: no_broadening + Card 5 absent (2 fields, next is TRANSMISSION)
815    // - tr037: no_broadening + Card 5 absent (4 fields, next is TRANSMISSION)
816    //
817    // Heuristic: when no_broadening, peek at the line AFTER the current one.
818    // If that next line is also numeric (starts with digit/sign/dot), then
819    // the current line is Card 5 (and the next is Card 6).  If the next
820    // line is a keyword (starts with alpha), the current line is Card 6
821    // (Card 5 was skipped).
822    let card5_present = if idx < lines.len() {
823        if no_broadening {
824            // Look ahead to determine if Card 5 is present.
825            if idx + 1 < lines.len() {
826                let next_trimmed = lines[idx + 1].trim();
827                next_trimmed
828                    .bytes()
829                    .next()
830                    .is_some_and(|b| b.is_ascii_digit() || b == b'.' || b == b'-' || b == b'+')
831            } else {
832                false
833            }
834        } else {
835            true // Broadening active → Card 5 always present.
836        }
837    } else {
838        false
839    };
840
841    if card5_present && idx < lines.len() {
842        let card5_fields: Vec<&str> = lines[idx].split_whitespace().collect();
843        if card5_fields.len() >= 2 {
844            temperature_k = card5_fields[0]
845                .parse::<f64>()
846                .map_err(|e| SammyParseError::new(format!("Card 5: temperature: {e}")))?;
847            flight_path_m = card5_fields[1]
848                .parse::<f64>()
849                .map_err(|e| SammyParseError::new(format!("Card 5: flight_path: {e}")))?;
850            // SAMMY Card 5 field order: Temp, Dist, Deltal, Deltae, Deltag
851            // Ref: SAMMY Users' Guide Table IVA.1.
852            if card5_fields.len() >= 3 {
853                delta_l_sammy = card5_fields[2].parse().unwrap_or(0.0);
854            }
855            if card5_fields.len() >= 4 {
856                delta_e_sammy = card5_fields[3].parse().unwrap_or(0.0);
857            }
858            if card5_fields.len() >= 5 {
859                delta_g_sammy = card5_fields[4].parse().unwrap_or(0.0);
860            }
861        }
862        idx += 1;
863    }
864
865    // Card 6: scattering radius (required) and thickness (required).
866    if idx < lines.len() {
867        let card6_fields: Vec<&str> = lines[idx].split_whitespace().collect();
868        if card6_fields.len() >= 2 {
869            scattering_radius_fm = card6_fields[0]
870                .parse::<f64>()
871                .map_err(|e| SammyParseError::new(format!("Card 6: scattering_radius: {e}")))?;
872            thickness_atoms_barn = card6_fields[1]
873                .parse::<f64>()
874                .map_err(|e| SammyParseError::new(format!("Card 6: thickness: {e}")))?;
875        }
876        idx += 1;
877    }
878
879    // Scan for observation keyword and spin group block, then BROADENING card.
880    let mut broadening_delta_l: Option<f64> = None;
881    let mut broadening_delta_g: Option<f64> = None;
882    let mut broadening_delta_e: Option<f64> = None;
883    let mut observation_type = SammyObservationType::default();
884
885    while idx < lines.len() {
886        let trimmed = lines[idx].trim().to_uppercase();
887        // SAMMY allows keyword abbreviations: "TRANS" matches "TRANSMISSION",
888        // "TOTAL" matches "TOTAL CROSS SECTION", "FISSI" matches "FISSION".
889        // Reject unsupported observation types early instead of silently
890        // defaulting to Transmission.
891        if trimmed.starts_with("CAPTU") {
892            return Err(SammyParseError::new(
893                "unsupported observation type: CAPTURE (not yet implemented in NEREIDS)",
894            ));
895        }
896        if trimmed.starts_with("DIRAC") {
897            return Err(SammyParseError::new(
898                "unsupported observation type: DIRAC (not yet implemented in NEREIDS)",
899            ));
900        }
901        if trimmed.starts_with("TRANS")
902            || trimmed.starts_with("TOTAL")
903            || trimmed.starts_with("FISSI")
904        {
905            if trimmed.starts_with("TOTAL") {
906                observation_type = SammyObservationType::TotalCrossSection;
907            } else if trimmed.starts_with("FISSI") {
908                observation_type = SammyObservationType::Fission;
909            }
910            idx += 1;
911            // Skip data-reduction parameter lines (SAMMY Card 8) that can
912            // appear between the TRANSMISSION keyword and spin group
913            // definitions.  Spin group headers have a positive integer in
914            // columns 0-4; Card 8 lines have floats (e.g. "0.0 0.0 0 1"
915            // in tr025).
916            while idx < lines.len() {
917                let l = lines[idx];
918                let field = if l.len() >= 5 {
919                    l[..5].trim()
920                } else {
921                    l.trim()
922                };
923                if field.is_empty() || field.parse::<u32>().is_ok() {
924                    break;
925                }
926                idx += 1;
927            }
928            // Parse spin group definitions until blank line.
929            let (groups, parsed_target_spin) = parse_spin_groups(&lines[idx..])?;
930            spin_groups = groups;
931            target_spin = parsed_target_spin;
932            // Advance past spin group block.
933            while idx < lines.len() && !lines[idx].trim().is_empty() {
934                idx += 1;
935            }
936        } else if trimmed.starts_with("BROADENING") {
937            // BROADENING card: next line has 6 floats + flags.
938            //
939            // SAMMY format (minp18.f90):
940            //   Uuu(1-3) = channel_radius, Temp, thickness (broadenPars)
941            //   Uuu(4-6) = Deltal, Deltag, Deltae (rslRes params 1-3)
942            //
943            // Non-zero Uuu(4-6) override Card 5 values.
944            idx += 1;
945            if idx < lines.len() {
946                let brd_fields: Vec<&str> = lines[idx].split_whitespace().collect();
947                if brd_fields.len() >= 6 {
948                    if let Ok(v) = brd_fields[3].parse::<f64>()
949                        && v != 0.0
950                    {
951                        broadening_delta_l = Some(v);
952                    }
953                    if let Ok(v) = brd_fields[4].parse::<f64>()
954                        && v != 0.0
955                    {
956                        broadening_delta_g = Some(v);
957                    }
958                    if let Ok(v) = brd_fields[5].parse::<f64>()
959                        && v != 0.0
960                    {
961                        broadening_delta_e = Some(v);
962                    }
963                }
964            }
965            // Only use the first BROADENING card (subsequent ones are for
966            // AdjustableObject save/restore operations and may be empty).
967            break;
968        }
969        idx += 1;
970    }
971
972    Ok(SammyInpConfig {
973        title,
974        isotope_symbol,
975        awr,
976        energy_min_ev,
977        energy_max_ev,
978        temperature_k,
979        flight_path_m,
980        delta_l_sammy,
981        delta_e_sammy,
982        delta_g_sammy,
983        no_broadening,
984        broadening_delta_l,
985        broadening_delta_g,
986        broadening_delta_e,
987        scattering_radius_fm,
988        thickness_atoms_barn,
989        target_spin,
990        spin_groups,
991        observation_type,
992    })
993}
994
995/// Parse spin group definitions from Card Set 10.
996///
997/// Each spin group has a header line and one or more channel lines:
998/// ```text
999///   1      1    0   .5       1.0   .0       <- group header
1000///     1    1    0    0      .500             <- channel line (indented)
1001///   2      1    0  -.5       1.0   .0
1002///     1    1    0    1      .500
1003/// ```
1004///
1005/// Header columns (unified for old and new format, verified against
1006/// SAMMY ResonanceParameterIO.cpp:931-938):
1007///   [0:5/0:3]   KKK   — spin group index
1008///   [5:10/7:10] NENT  — number of entrance channels
1009///   [10:15/12:15] NEXT — number of exit-only channels
1010///   [15:20] SPINJ     — J value (F5.1)
1011///   [20:30] ABNDNC    — abundance (F10.0)
1012///   [30:35] SPINI     — target spin (F5.1)
1013///   [36:]   label
1014///
1015/// Channel lines: L at cols 18-19 in both formats.
1016///
1017/// Returns (spin_groups, target_spin).
1018fn parse_spin_groups(lines: &[&str]) -> Result<(Vec<SammySpinGroup>, f64), SammyParseError> {
1019    let mut groups = Vec::new();
1020    let mut target_spin = 0.0;
1021    let mut i = 0;
1022
1023    /// Extract a trimmed substring from a fixed-width field, returning "" if out of bounds.
1024    fn col(line: &str, start: usize, end: usize) -> &str {
1025        if start >= line.len() {
1026            return "";
1027        }
1028        let actual_end = end.min(line.len());
1029        line[start..actual_end].trim()
1030    }
1031
1032    while i < lines.len() {
1033        let trimmed = lines[i].trim();
1034        if trimmed.is_empty() {
1035            break; // End of spin group block.
1036        }
1037
1038        let line = lines[i];
1039
1040        // Spin group header line (see doc comment above for old/new layouts).
1041        //
1042        // Fields common to both formats: index, n_ent, n_exit occupy cols 0-14
1043        // (I5 in old, I3+gaps in new — but cols 0-14 parse identically by
1044        // trimming).  J is always at cols 15-19 (F5.1).
1045        //
1046        // Abundance: F10.0 at cols 20-29 (unified, both formats).
1047        // Target spin: F5.1 at cols 30-34 (unified, both formats).
1048        // SAMMY Ref: ResonanceParameterIO.cpp:931-938.
1049        //
1050        // Total channel lines following = n_ent + n_exit.
1051        let index: u32 = col(line, 0, 5)
1052            .parse()
1053            .map_err(|e| SammyParseError::new(format!("spin group index: {e}")))?;
1054        let n_ent: u32 = {
1055            let s = col(line, 5, 10);
1056            if s.is_empty() {
1057                1
1058            } else {
1059                s.parse()
1060                    .map_err(|e| SammyParseError::new(format!("spin group n_ent ({s:?}): {e}")))?
1061            }
1062        };
1063        let n_exit: u32 = {
1064            let s = col(line, 10, 15);
1065            if s.is_empty() {
1066                0
1067            } else {
1068                s.parse()
1069                    .map_err(|e| SammyParseError::new(format!("spin group n_exit ({s:?}): {e}")))?
1070            }
1071        };
1072        let j: f64 = col(line, 15, 20)
1073            .parse()
1074            .map_err(|e| SammyParseError::new(format!("spin group J: {e}")))?;
1075        // Preserve the sign: SAMMY uses negative J to distinguish spin
1076        // groups with the same |J|.  The sign keeps them in separate
1077        // J-groups during cross-section evaluation.
1078        // Abundance: 10 chars at cols 20-29 (F10.0) in both formats.
1079        // SAMMY Ref: ResonanceParameterIO.cpp:931 "10 spaces for abundance (21-30)"
1080        // (SAMMY uses 1-based column numbering; 0-based = 20-29).
1081        let abund_end = 30;
1082        let abundance: f64 = {
1083            let s = col(line, 20, abund_end);
1084            if s.is_empty() {
1085                1.0
1086            } else {
1087                s.parse().map_err(|e| {
1088                    SammyParseError::new(format!("spin group abundance ({s:?}): {e}"))
1089                })?
1090            }
1091        };
1092
1093        // Target spin: 5 chars at cols 30-34 in both formats.
1094        // SAMMY Ref: ResonanceParameterIO.cpp:938 "5 spaces for target spin (31-35)"
1095        let tspin_start = 30;
1096        let group_target_spin = {
1097            let s = col(line, tspin_start, tspin_start + 5);
1098            if s.is_empty() {
1099                0.0
1100            } else {
1101                s.parse::<f64>().map_err(|e| {
1102                    SammyParseError::new(format!("spin group target spin ({s:?}): {e}"))
1103                })?
1104            }
1105        };
1106        // Use first group's target spin as the global default.
1107        if groups.is_empty() {
1108            target_spin = group_target_spin;
1109        }
1110
1111        // Optional isotope label: text after column 36, trimmed.
1112        // Multi-isotope cases (e.g. tr034) store labels like "Cu65", "Cu63".
1113        let isotope_label = if line.len() > 36 {
1114            let s = line[36..].trim();
1115            if s.is_empty() {
1116                None
1117            } else {
1118                Some(s.to_string())
1119            }
1120        } else {
1121            None
1122        };
1123
1124        // Extract L from the first channel line following this header.
1125        //
1126        // SAMMY Card Set 10.1 (old format) channel card columns
1127        // (from ResonanceParameterIO.cpp:readOldChannelData):
1128        //   cols  0-2: spin group index (I3)
1129        //   cols  3-4: channel ID (I2)
1130        //   cols  5-7: kz1 (charge, usually blank)
1131        //   cols  8-9: lpent
1132        //   cols 10-12: kz2 (charge, usually blank)
1133        //   cols 13-14: ishift
1134        //   cols 15-17: ifexcl (usually blank)
1135        //   cols 18-19: L (orbital angular momentum)  ← this is what we need
1136        //   cols 20-29: channel spin
1137        //
1138        // Use fixed-width column extraction (cols 18-20) instead of
1139        // whitespace split, which breaks when blank fields (kz1, kz2,
1140        // ifexcl) are present or absent.
1141        let n_channels = n_ent + n_exit;
1142        let mut l = 0u32;
1143        if i + 1 < lines.len() {
1144            let l_str = col(lines[i + 1], 18, 20);
1145            if !l_str.is_empty() {
1146                l = l_str.parse().unwrap_or(0);
1147            }
1148        }
1149
1150        groups.push(SammySpinGroup {
1151            index,
1152            j,
1153            l,
1154            abundance,
1155            target_spin: group_target_spin,
1156            isotope_label,
1157        });
1158
1159        // Skip channel lines: n_ent + n_exit lines follow the header.
1160        i += 1 + n_channels as usize;
1161    }
1162
1163    Ok((groups, target_spin))
1164}
1165
1166// ─── Converter: SAMMY → NEREIDS ResonanceData ──────────────────────────────────
1167
1168/// Small dimensionless perturbation added to J values to distinguish spin groups
1169/// with the same |J| but different channel structure.
1170const CHANNEL_OFFSET: f64 = 1e-6;
1171
1172/// Compute J-value offsets for spin groups that share the same (L, |J|) pair.
1173///
1174/// For targets with non-zero spin (I > 0), multiple spin groups can have
1175/// the same (L, |J|) but represent different entrance channels (different
1176/// channel spin s = I +/- 1/2). These must be kept separate in the R-matrix
1177/// calculation. We assign a small perturbation (1e-6 per duplicate) to
1178/// distinguish them.
1179///
1180/// For I = 0 (even-even nuclei), duplicate (L, J) spin groups represent
1181/// pseudo-isotope contaminants, not extra channels, so no offset is applied.
1182///
1183/// Ref: SAMMY Manual Eq. III A.1, sum over entrance channels c.
1184fn compute_j_offsets(
1185    spin_groups: &[SammySpinGroup],
1186    target_spin: f64,
1187) -> std::collections::HashMap<u32, f64> {
1188    let mut sg_j_offset = std::collections::HashMap::new();
1189    if target_spin.abs() <= 1e-10 {
1190        return sg_j_offset;
1191    }
1192    let mut lj_seen: std::collections::HashMap<(u32, i64), u32> = std::collections::HashMap::new();
1193    for sg in spin_groups {
1194        let j_key = (sg.j.abs() * 1_000_000.0).round() as i64;
1195        let count = lj_seen.entry((sg.l, j_key)).or_insert(0);
1196        if *count > 0 {
1197            sg_j_offset.insert(sg.index, *count as f64 * CHANNEL_OFFSET);
1198        }
1199        *count += 1;
1200    }
1201    sg_j_offset
1202}
1203
1204/// Convert parsed SAMMY `.par` + `.inp` into a NEREIDS `ResonanceData`.
1205///
1206/// Groups resonances by spin group, maps each group to an `LGroup` using the
1207/// spin group definitions from the `.inp` file.
1208pub fn sammy_to_resonance_data(
1209    inp: &SammyInpConfig,
1210    par: &SammyParFile,
1211) -> Result<ResonanceData, SammyParseError> {
1212    if inp.spin_groups.is_empty() {
1213        return Err(SammyParseError::new("no spin groups defined in .inp file"));
1214    }
1215
1216    // Build a map: spin_group_index → (L, J, abundance).
1217    let group_map: std::collections::HashMap<u32, &SammySpinGroup> =
1218        inp.spin_groups.iter().map(|sg| (sg.index, sg)).collect();
1219
1220    // Group resonances by L-value (NEREIDS groups by L, not by spin group).
1221    // Within each L-group, resonances carry their own J value.
1222    let mut l_group_map: std::collections::BTreeMap<u32, Vec<Resonance>> =
1223        std::collections::BTreeMap::new();
1224
1225    // Multi-channel J offset: for targets with I > 0, perturb duplicate
1226    // (L, |J|) spin groups so each entrance channel gets its own J-group.
1227    // See `compute_j_offsets` doc comment for full explanation.
1228    let sg_j_offset = compute_j_offsets(&inp.spin_groups, inp.target_spin);
1229
1230    for res in &par.resonances {
1231        let sg = group_map.get(&res.spin_group).ok_or_else(|| {
1232            SammyParseError::new(format!(
1233                "resonance at E={} eV references undefined spin group {}",
1234                res.energy_ev, res.spin_group
1235            ))
1236        })?;
1237
1238        let j = sg.j + sg_j_offset.get(&sg.index).copied().unwrap_or(0.0);
1239        l_group_map.entry(sg.l).or_default().push(Resonance {
1240            energy: res.energy_ev,
1241            j,
1242            gn: res.gamma_n_ev,
1243            gg: res.gamma_gamma_ev,
1244            gfa: res.gamma_f1_ev,
1245            gfb: res.gamma_f2_ev,
1246        });
1247    }
1248
1249    // Inject zero-width sentinel resonances for spin groups that have no
1250    // resonances in the .par file.  These spin groups still contribute
1251    // potential scattering: R=0 → U = e^{-2iφ_L} → σ_pot ≠ 0.
1252    // Without sentinels, the L-group (or J-group within it) is never
1253    // created, and the potential scattering is silently lost.
1254    let sg_indices_with_resonances: std::collections::HashSet<u32> =
1255        par.resonances.iter().map(|r| r.spin_group).collect();
1256    for sg in &inp.spin_groups {
1257        if !sg_indices_with_resonances.contains(&sg.index) {
1258            let j = sg.j + sg_j_offset.get(&sg.index).copied().unwrap_or(0.0);
1259            l_group_map.entry(sg.l).or_default().push(Resonance {
1260                energy: 0.0,
1261                j,
1262                gn: 0.0,
1263                gg: 0.0,
1264                gfa: 0.0,
1265                gfb: 0.0,
1266            });
1267        }
1268    }
1269
1270    // Build per-L-group radius from par file "RADIUS PARAMETERS FOLLOW".
1271    // SAMMY assigns radii per spin group; NEREIDS uses per-L-group (apl).
1272    // Map: L → radius by looking up each spin group's L value and its
1273    // assigned radius.  All spin groups with the same L should have the
1274    // same radius (verified in SAMMY test cases).
1275    //
1276    // Ref: SAMMY AdjustedRadiusData.cpp readOldStyle
1277    let mut l_radius_map: std::collections::HashMap<u32, f64> = std::collections::HashMap::new();
1278    for sg in &inp.spin_groups {
1279        if let Some(&r) = par.radius_overrides.get(&sg.index) {
1280            l_radius_map.entry(sg.l).or_insert(r);
1281        }
1282    }
1283
1284    // Build LGroups.
1285    let l_groups: Vec<LGroup> = l_group_map
1286        .into_iter()
1287        .map(|(l, resonances)| {
1288            // Use per-L-group radius if available, else fall back to global
1289            // (apl=0.0 means "use range.scattering_radius").
1290            let apl = l_radius_map.get(&l).copied().unwrap_or(0.0);
1291            LGroup {
1292                l,
1293                awr: inp.awr,
1294                apl,
1295                qx: 0.0,
1296                lrx: 0,
1297                resonances,
1298            }
1299        })
1300        .collect();
1301
1302    // Use the target spin parsed from the spin group header (field 6).
1303    // For even-even nuclei (Fe-56, Ni-58, Ni-60) this is 0.0.
1304    let target_spin = inp.target_spin;
1305
1306    // Infer Z and A from the isotope symbol (e.g. "60NI" → Z=28, A=60;
1307    // "FE56" → Z=26, A=56; "58NI" → Z=28, A=58; "NatFE" → Z=26, A=0).
1308    let (z, mut a) = parse_isotope_symbol(&inp.isotope_symbol)?;
1309    // For natural-element symbols (A=0), approximate A from AWR.
1310    if a == 0 {
1311        a = inp.awr.round() as u32;
1312    }
1313    let za = z * 1000 + a;
1314
1315    // Use a wide energy range so that cross-sections can be evaluated at any
1316    // energy in the .plt reference file, which may extend beyond the .inp
1317    // analysis window.  SAMMY's .inp energy limits define the fitting region,
1318    // not the physics validity bounds of the resonance parameters.
1319    // R-external is NOT included in the single-isotope path.
1320    //
1321    // The single-isotope path merges ALL spin groups (including minor isotopes)
1322    // into one ResonanceRange.  R-external entries from one isotope's spin groups
1323    // could erroneously match another isotope's (L, J) group, corrupting the
1324    // cross section.  Use sammy_to_resonance_data_multi() for cases with R-ext.
1325
1326    // Same no-inert-data invariant as the ENDF load path and the Python
1327    // constructor: a range with no resonances in any L-group evaluates to
1328    // zero cross-section everywhere. A valid .par always carries resonances
1329    // (or spin groups, which inject zero-width sentinels above).
1330    if l_groups.iter().all(|lg| lg.resonances.is_empty()) {
1331        return Err(SammyParseError::new(
1332            "SAMMY .par produced no resonances in any L-group; cross-sections \
1333             would be identically zero everywhere"
1334                .to_string(),
1335        ));
1336    }
1337
1338    let range = ResonanceRange {
1339        energy_low: 1e-5,
1340        energy_high: 2e7,
1341        resolved: true,
1342        formalism: ResonanceFormalism::ReichMoore,
1343        target_spin,
1344        scattering_radius: inp.scattering_radius_fm,
1345        // SAMMY always uses AP for penetrability (equivalent to ENDF NAPS=1).
1346        naps: 1,
1347        ap_table: None,
1348        l_groups,
1349        r_external: vec![],
1350    };
1351
1352    Ok(ResonanceData {
1353        isotope: Isotope::new(z, a)
1354            .map_err(|e| SammyParseError::new(format!("invalid isotope: {e}")))?,
1355        za,
1356        awr: inp.awr,
1357        ranges: vec![range],
1358    })
1359}
1360
1361/// Convert parsed SAMMY `.par` + `.inp` into multiple `(ResonanceData, abundance)` pairs.
1362///
1363/// Groups spin groups into "pseudo-isotopes" by isotope label (if present) or
1364/// by (abundance, target_spin).  Each group gets its own `ResonanceData` with
1365/// only the resonances belonging to that group's spin groups.
1366///
1367/// This is needed for multi-isotope SAMMY cases like tr024 (NatFe: Fe-56 +
1368/// Fe-54 + Fe-57) and tr034 (Cu-65 + Cu-63).
1369pub fn sammy_to_resonance_data_multi(
1370    inp: &SammyInpConfig,
1371    par: &SammyParFile,
1372) -> Result<Vec<(ResonanceData, f64)>, SammyParseError> {
1373    if inp.spin_groups.is_empty() {
1374        return Err(SammyParseError::new("no spin groups defined in .inp file"));
1375    }
1376
1377    // Determine grouping key for each spin group.
1378    // If ANY spin group has an isotope_label, group by label.
1379    // Otherwise, group by (abundance, target_spin) using a string key.
1380    let has_labels = inp.spin_groups.iter().any(|sg| sg.isotope_label.is_some());
1381
1382    // Build grouping: key → (spin_group_indices, abundance, target_spin).
1383    let mut group_order: Vec<String> = Vec::new();
1384    let mut group_members: std::collections::HashMap<String, Vec<usize>> =
1385        std::collections::HashMap::new();
1386    let mut group_abundance: std::collections::HashMap<String, f64> =
1387        std::collections::HashMap::new();
1388    let mut group_target_spin: std::collections::HashMap<String, f64> =
1389        std::collections::HashMap::new();
1390
1391    for (i, sg) in inp.spin_groups.iter().enumerate() {
1392        let key = if has_labels {
1393            sg.isotope_label
1394                .as_deref()
1395                .unwrap_or("unknown")
1396                .to_uppercase()
1397        } else {
1398            format!("{:.6}_{:.1}", sg.abundance, sg.target_spin)
1399        };
1400
1401        if !group_members.contains_key(&key) {
1402            group_order.push(key.clone());
1403        }
1404        group_members.entry(key.clone()).or_default().push(i);
1405        if let Some(&existing) = group_abundance.get(&key)
1406            && (existing - sg.abundance).abs() >= 1e-12
1407        {
1408            return Err(SammyParseError::new(format!(
1409                "spin groups in isotope group '{}' disagree on abundance: {} vs {}",
1410                key, existing, sg.abundance
1411            )));
1412        }
1413        group_abundance.insert(key.clone(), sg.abundance);
1414        if let Some(&existing) = group_target_spin.get(&key)
1415            && (existing - sg.target_spin).abs() >= 1e-12
1416        {
1417            return Err(SammyParseError::new(format!(
1418                "spin groups in isotope group '{}' disagree on target_spin: {} vs {}",
1419                key, existing, sg.target_spin
1420            )));
1421        }
1422        group_target_spin.insert(key.clone(), sg.target_spin);
1423    }
1424
1425    // Build spin_group_index → SammySpinGroup map.
1426    let sg_map: std::collections::HashMap<u32, &SammySpinGroup> =
1427        inp.spin_groups.iter().map(|sg| (sg.index, sg)).collect();
1428
1429    // Build spin_group → (awr, abundance) from ISOTOPIC MASSES section if present.
1430    // These override the .inp spin group abundances and provide per-isotope AWR.
1431    let mut sg_iso_awr: std::collections::HashMap<u32, f64> = std::collections::HashMap::new();
1432    let mut sg_iso_abund: std::collections::HashMap<u32, f64> = std::collections::HashMap::new();
1433    for im in &par.isotopic_masses {
1434        for &sg_idx in &im.spin_groups {
1435            sg_iso_awr.insert(sg_idx, im.awr);
1436            sg_iso_abund.insert(sg_idx, im.abundance);
1437        }
1438    }
1439
1440    // Z from Card 2 (shared across all groups).
1441    let (card2_z, card2_a) = parse_isotope_symbol(&inp.isotope_symbol)?;
1442
1443    let mut result = Vec::new();
1444
1445    for key in &group_order {
1446        let member_indices = &group_members[key];
1447        let target_spin = group_target_spin[key];
1448
1449        // Use ISOTOPIC MASSES abundance when available (overrides .inp spin
1450        // group headers).  Fall back to .inp abundance.
1451        let first_sg_idx = inp.spin_groups[member_indices[0]].index;
1452        let abundance = sg_iso_abund
1453            .get(&first_sg_idx)
1454            .copied()
1455            .unwrap_or(group_abundance[key]);
1456
1457        // Collect the spin group indices (1-based) for this group.
1458        let sg_indices: std::collections::HashSet<u32> = member_indices
1459            .iter()
1460            .map(|&i| inp.spin_groups[i].index)
1461            .collect();
1462
1463        // Filter resonances belonging to this group.
1464        let group_resonances: Vec<&SammyResonance> = par
1465            .resonances
1466            .iter()
1467            .filter(|r| sg_indices.contains(&r.spin_group))
1468            .collect();
1469
1470        // Multi-channel J offset for this isotope group's spin groups.
1471        // See `compute_j_offsets` doc comment for full explanation.
1472        let member_sgs: Vec<SammySpinGroup> = member_indices
1473            .iter()
1474            .map(|&i| inp.spin_groups[i].clone())
1475            .collect();
1476        let sg_j_offset = compute_j_offsets(&member_sgs, target_spin);
1477
1478        // Build L-groups from these resonances.
1479        let mut l_group_map: std::collections::BTreeMap<u32, Vec<Resonance>> =
1480            std::collections::BTreeMap::new();
1481
1482        for res in &group_resonances {
1483            let sg = sg_map.get(&res.spin_group).ok_or_else(|| {
1484                SammyParseError::new(format!(
1485                    "resonance at E={} eV references undefined spin group {}",
1486                    res.energy_ev, res.spin_group
1487                ))
1488            })?;
1489
1490            let j = sg.j + sg_j_offset.get(&sg.index).copied().unwrap_or(0.0);
1491            l_group_map.entry(sg.l).or_default().push(Resonance {
1492                energy: res.energy_ev,
1493                j,
1494                gn: res.gamma_n_ev,
1495                gg: res.gamma_gamma_ev,
1496                gfa: res.gamma_f1_ev,
1497                gfb: res.gamma_f2_ev,
1498            });
1499        }
1500
1501        // Inject zero-width sentinels for spin groups in this isotope
1502        // group that have no resonances (same fix as sammy_to_resonance_data).
1503        let res_sg_indices: std::collections::HashSet<u32> =
1504            group_resonances.iter().map(|r| r.spin_group).collect();
1505        for &idx in &sg_indices {
1506            if !res_sg_indices.contains(&idx)
1507                && let Some(sg) = sg_map.get(&idx)
1508            {
1509                let j = sg.j + sg_j_offset.get(&sg.index).copied().unwrap_or(0.0);
1510                l_group_map.entry(sg.l).or_default().push(Resonance {
1511                    energy: 0.0,
1512                    j,
1513                    gn: 0.0,
1514                    gg: 0.0,
1515                    gfa: 0.0,
1516                    gfb: 0.0,
1517                });
1518            }
1519        }
1520
1521        // Build per-L radius map for this isotope group's spin groups.
1522        let mut l_radius_map: std::collections::HashMap<u32, f64> =
1523            std::collections::HashMap::new();
1524        for &idx in &sg_indices {
1525            if let Some(sg) = sg_map.get(&idx)
1526                && let Some(&r) = par.radius_overrides.get(&sg.index)
1527            {
1528                l_radius_map.entry(sg.l).or_insert(r);
1529            }
1530        }
1531
1532        // Per-isotope AWR: use ISOTOPIC MASSES value when available,
1533        // fall back to .inp global AWR from Card 2.
1534        let isotope_awr = sg_iso_awr.get(&first_sg_idx).copied().unwrap_or(inp.awr);
1535
1536        let l_groups: Vec<LGroup> = l_group_map
1537            .into_iter()
1538            .map(|(l, resonances)| {
1539                let apl = l_radius_map.get(&l).copied().unwrap_or(0.0);
1540                LGroup {
1541                    l,
1542                    awr: isotope_awr,
1543                    apl,
1544                    qx: 0.0,
1545                    lrx: 0,
1546                    resonances,
1547                }
1548            })
1549            .collect();
1550
1551        // Determine Z/A for this group.
1552        let (z, mut a) = if has_labels {
1553            // Try to parse the isotope label (e.g. "Cu65" → Z=29, A=65).
1554            parse_isotope_symbol(key).unwrap_or((card2_z, card2_a))
1555        } else {
1556            (card2_z, card2_a)
1557        };
1558        // For natural-element symbols (A=0), approximate A from AWR.
1559        if a == 0 {
1560            a = isotope_awr.round() as u32;
1561        }
1562        let za = z * 1000 + a;
1563
1564        // Build R-external entries for this isotope group's spin groups.
1565        let mut r_external_entries = Vec::new();
1566        for &idx in &sg_indices {
1567            if let Some(sg) = sg_map.get(&idx)
1568                && let Some(params) = par.r_external.get(&sg.index)
1569            {
1570                let j = sg.j + sg_j_offset.get(&sg.index).copied().unwrap_or(0.0);
1571                r_external_entries.push(RExternalEntry {
1572                    l: sg.l,
1573                    j,
1574                    e_low: params[0],
1575                    e_up: params[1],
1576                    r_con: params[2],
1577                    r_lin: params[3],
1578                    s_con: params[4],
1579                    s_lin: params[5],
1580                    r_quad: params[6],
1581                });
1582            }
1583        }
1584
1585        // Same no-inert-data invariant as the single-isotope path: an
1586        // isotope group whose L-groups carry no resonances would evaluate
1587        // to zero cross-section everywhere.
1588        if l_groups.iter().all(|lg| lg.resonances.is_empty()) {
1589            return Err(SammyParseError::new(format!(
1590                "SAMMY .par isotope group {key:?} produced no resonances in \
1591                 any L-group; cross-sections would be identically zero \
1592                 everywhere"
1593            )));
1594        }
1595
1596        let range = ResonanceRange {
1597            energy_low: 1e-5,
1598            energy_high: 2e7,
1599            resolved: true,
1600            formalism: ResonanceFormalism::ReichMoore,
1601            target_spin,
1602            scattering_radius: inp.scattering_radius_fm,
1603            // SAMMY always uses AP for penetrability (equivalent to ENDF NAPS=1).
1604            naps: 1,
1605            ap_table: None,
1606            l_groups,
1607            r_external: r_external_entries,
1608        };
1609
1610        let resonance_data = ResonanceData {
1611            isotope: Isotope::new(z, a)
1612                .map_err(|e| SammyParseError::new(format!("invalid isotope: {e}")))?,
1613            za,
1614            awr: isotope_awr,
1615            ranges: vec![range],
1616        };
1617
1618        result.push((resonance_data, abundance));
1619    }
1620
1621    Ok(result)
1622}
1623
1624/// Parse a SAMMY isotope symbol like "60NI", "FE56", "58NI", "NatFE",
1625/// "CU 65", "Cu65" into (Z, A).
1626///
1627/// For natural-element symbols like "NatFE", A=0 is returned (no specific
1628/// mass number).  Space-separated labels like "CU 65" are handled by
1629/// stripping internal spaces.
1630pub(crate) fn parse_isotope_symbol(symbol: &str) -> Result<(u32, u32), SammyParseError> {
1631    // Strip internal spaces and hyphens (handles "CU 65" → "CU65", "Rh-103" → "RH103").
1632    let joined: String = symbol
1633        .chars()
1634        .filter(|c| !c.is_whitespace() && *c != '-')
1635        .collect();
1636    let s = joined.to_uppercase();
1637
1638    // Handle "NAT" prefix: "NATFE" → Z from element, A=0.
1639    if let Some(rest) = s.strip_prefix("NAT")
1640        && let Some(z) = element_symbol_to_z(rest)
1641    {
1642        return Ok((z, 0));
1643    }
1644
1645    // Try format: "60NI" (mass number first, then element symbol).
1646    // Extract just the leading alphabetic chars as element symbol to handle
1647    // labels with trailing comments (e.g. "94Zr ... FAKES" → "ZR").
1648    if let Some(split_pos) = s.find(|c: char| c.is_ascii_alphabetic()) {
1649        let mass_str = &s[..split_pos];
1650        let remaining = &s[split_pos..];
1651        let alpha_len = remaining
1652            .find(|c: char| !c.is_ascii_alphabetic())
1653            .unwrap_or(remaining.len());
1654        let elem_str = &remaining[..alpha_len];
1655        if !mass_str.is_empty()
1656            && let Ok(a) = mass_str.parse::<u32>()
1657            && let Some(z) = element_symbol_to_z(elem_str)
1658        {
1659            return Ok((z, a));
1660        }
1661    }
1662
1663    // Try format: "FE56" (element symbol first, then mass number).
1664    // Extract just the leading digit chars to handle trailing garbage.
1665    if let Some(split_pos) = s.find(|c: char| c.is_ascii_digit()) {
1666        let elem_str = &s[..split_pos];
1667        let remaining = &s[split_pos..];
1668        let digit_len = remaining
1669            .find(|c: char| !c.is_ascii_digit())
1670            .unwrap_or(remaining.len());
1671        let mass_str = &remaining[..digit_len];
1672        if let Ok(a) = mass_str.parse::<u32>()
1673            && let Some(z) = element_symbol_to_z(elem_str)
1674        {
1675            return Ok((z, a));
1676        }
1677    }
1678
1679    // Try bare element symbol without mass number (e.g. "Al" → Z=13, A=0).
1680    if let Some(z) = element_symbol_to_z(&s) {
1681        return Ok((z, 0));
1682    }
1683
1684    // Try full element name (e.g. "ZIRCONIUM" → Z=40, A=0 natural).
1685    if let Some(z) = element_name_to_z(&s) {
1686        return Ok((z, 0));
1687    }
1688
1689    Err(SammyParseError::new(format!(
1690        "cannot parse isotope symbol: {symbol}"
1691    )))
1692}
1693
1694/// Map element symbol (uppercase) to atomic number Z.
1695///
1696/// Covers all elements Z=1 (H) through Z=100 (Fm).
1697fn element_symbol_to_z(symbol: &str) -> Option<u32> {
1698    match symbol {
1699        "H" => Some(1),
1700        "HE" => Some(2),
1701        "LI" => Some(3),
1702        "BE" => Some(4),
1703        "B" => Some(5),
1704        "C" => Some(6),
1705        "N" => Some(7),
1706        "O" => Some(8),
1707        "F" => Some(9),
1708        "NE" => Some(10),
1709        "NA" => Some(11),
1710        "MG" => Some(12),
1711        "AL" => Some(13),
1712        "SI" => Some(14),
1713        "P" => Some(15),
1714        "S" => Some(16),
1715        "CL" => Some(17),
1716        "AR" => Some(18),
1717        "K" => Some(19),
1718        "CA" => Some(20),
1719        "SC" => Some(21),
1720        "TI" => Some(22),
1721        "V" => Some(23),
1722        "CR" => Some(24),
1723        "MN" => Some(25),
1724        "FE" => Some(26),
1725        "CO" => Some(27),
1726        "NI" => Some(28),
1727        "CU" => Some(29),
1728        "ZN" => Some(30),
1729        "GA" => Some(31),
1730        "GE" => Some(32),
1731        "AS" => Some(33),
1732        "SE" => Some(34),
1733        "BR" => Some(35),
1734        "KR" => Some(36),
1735        "RB" => Some(37),
1736        "SR" => Some(38),
1737        "Y" => Some(39),
1738        "ZR" => Some(40),
1739        "NB" => Some(41),
1740        "MO" => Some(42),
1741        "TC" => Some(43),
1742        "RU" => Some(44),
1743        "RH" => Some(45),
1744        "PD" => Some(46),
1745        "AG" => Some(47),
1746        "CD" => Some(48),
1747        "IN" => Some(49),
1748        "SN" => Some(50),
1749        "SB" => Some(51),
1750        "TE" => Some(52),
1751        "I" => Some(53),
1752        "XE" => Some(54),
1753        "CS" => Some(55),
1754        "BA" => Some(56),
1755        "LA" => Some(57),
1756        "CE" => Some(58),
1757        "PR" => Some(59),
1758        "ND" => Some(60),
1759        "PM" => Some(61),
1760        "SM" => Some(62),
1761        "EU" => Some(63),
1762        "GD" => Some(64),
1763        "TB" => Some(65),
1764        "DY" => Some(66),
1765        "HO" => Some(67),
1766        "ER" => Some(68),
1767        "TM" => Some(69),
1768        "YB" => Some(70),
1769        "LU" => Some(71),
1770        "HF" => Some(72),
1771        "TA" => Some(73),
1772        "W" => Some(74),
1773        "RE" => Some(75),
1774        "OS" => Some(76),
1775        "IR" => Some(77),
1776        "PT" => Some(78),
1777        "AU" => Some(79),
1778        "HG" => Some(80),
1779        "TL" => Some(81),
1780        "PB" => Some(82),
1781        "BI" => Some(83),
1782        "PO" => Some(84),
1783        "AT" => Some(85),
1784        "RN" => Some(86),
1785        "FR" => Some(87),
1786        "RA" => Some(88),
1787        "AC" => Some(89),
1788        "TH" => Some(90),
1789        "PA" => Some(91),
1790        "U" => Some(92),
1791        "NP" => Some(93),
1792        "PU" => Some(94),
1793        "AM" => Some(95),
1794        "CM" => Some(96),
1795        "BK" => Some(97),
1796        "CF" => Some(98),
1797        "ES" => Some(99),
1798        "FM" => Some(100),
1799        _ => None,
1800    }
1801}
1802
1803/// Map full element name (uppercase) to atomic number Z.
1804///
1805/// Handles SAMMY inp files that use full element names on Card 2
1806/// (e.g. "ZIRCONIUM" instead of "ZR").
1807fn element_name_to_z(name: &str) -> Option<u32> {
1808    match name {
1809        "HYDROGEN" => Some(1),
1810        "HELIUM" => Some(2),
1811        "LITHIUM" => Some(3),
1812        "BERYLLIUM" => Some(4),
1813        "BORON" => Some(5),
1814        "CARBON" => Some(6),
1815        "NITROGEN" => Some(7),
1816        "OXYGEN" => Some(8),
1817        "FLUORINE" => Some(9),
1818        "NEON" => Some(10),
1819        "SODIUM" => Some(11),
1820        "MAGNESIUM" => Some(12),
1821        "ALUMINUM" | "ALUMINIUM" => Some(13),
1822        "SILICON" => Some(14),
1823        "PHOSPHORUS" => Some(15),
1824        "SULFUR" | "SULPHUR" => Some(16),
1825        "CHLORINE" => Some(17),
1826        "ARGON" => Some(18),
1827        "POTASSIUM" => Some(19),
1828        "CALCIUM" => Some(20),
1829        "SCANDIUM" => Some(21),
1830        "TITANIUM" => Some(22),
1831        "VANADIUM" => Some(23),
1832        "CHROMIUM" => Some(24),
1833        "MANGANESE" => Some(25),
1834        "IRON" => Some(26),
1835        "COBALT" => Some(27),
1836        "NICKEL" => Some(28),
1837        "COPPER" => Some(29),
1838        "ZINC" => Some(30),
1839        "GALLIUM" => Some(31),
1840        "GERMANIUM" => Some(32),
1841        "ARSENIC" => Some(33),
1842        "SELENIUM" => Some(34),
1843        "BROMINE" => Some(35),
1844        "KRYPTON" => Some(36),
1845        "RUBIDIUM" => Some(37),
1846        "STRONTIUM" => Some(38),
1847        "YTTRIUM" => Some(39),
1848        "ZIRCONIUM" => Some(40),
1849        "NIOBIUM" => Some(41),
1850        "MOLYBDENUM" => Some(42),
1851        "TECHNETIUM" => Some(43),
1852        "RUTHENIUM" => Some(44),
1853        "RHODIUM" => Some(45),
1854        "PALLADIUM" => Some(46),
1855        "SILVER" => Some(47),
1856        "CADMIUM" => Some(48),
1857        "INDIUM" => Some(49),
1858        "TIN" => Some(50),
1859        "ANTIMONY" => Some(51),
1860        "TELLURIUM" => Some(52),
1861        "IODINE" => Some(53),
1862        "XENON" => Some(54),
1863        "CESIUM" | "CAESIUM" => Some(55),
1864        "BARIUM" => Some(56),
1865        "LANTHANUM" => Some(57),
1866        "CERIUM" => Some(58),
1867        "PRASEODYMIUM" => Some(59),
1868        "NEODYMIUM" => Some(60),
1869        "PROMETHIUM" => Some(61),
1870        "SAMARIUM" => Some(62),
1871        "EUROPIUM" => Some(63),
1872        "GADOLINIUM" => Some(64),
1873        "TERBIUM" => Some(65),
1874        "DYSPROSIUM" => Some(66),
1875        "HOLMIUM" => Some(67),
1876        "ERBIUM" => Some(68),
1877        "THULIUM" => Some(69),
1878        "YTTERBIUM" => Some(70),
1879        "LUTETIUM" => Some(71),
1880        "HAFNIUM" => Some(72),
1881        "TANTALUM" => Some(73),
1882        "TUNGSTEN" => Some(74),
1883        "RHENIUM" => Some(75),
1884        "OSMIUM" => Some(76),
1885        "IRIDIUM" => Some(77),
1886        "PLATINUM" => Some(78),
1887        "GOLD" => Some(79),
1888        "MERCURY" => Some(80),
1889        "THALLIUM" => Some(81),
1890        "LEAD" => Some(82),
1891        "BISMUTH" => Some(83),
1892        "THORIUM" => Some(90),
1893        "PROTACTINIUM" => Some(91),
1894        "URANIUM" => Some(92),
1895        "NEPTUNIUM" => Some(93),
1896        "PLUTONIUM" => Some(94),
1897        "AMERICIUM" => Some(95),
1898        "CURIUM" => Some(96),
1899        "BERKELIUM" => Some(97),
1900        "CALIFORNIUM" => Some(98),
1901        _ => None,
1902    }
1903}
1904
1905// ─── Tests ─────────────────────────────────────────────────────────────────────
1906
1907#[cfg(test)]
1908mod tests {
1909    use super::*;
1910
1911    /// Canonical SAMMY `.inp` fixture for the Fe-56 tr007 transmission
1912    /// case.  Loaded once and reused by the TRANSMISSION test directly
1913    /// and by the CAPTURE / DIRAC rejection tests via
1914    /// [`tr007_with_observation_type`] which swaps the observation-type
1915    /// line.  Eliminates the prior 3 inline near-duplicates.
1916    const FE56_TR007_INP: &str = include_str!(
1917        "../../../tests/data/samtry/tr007_fe56_transmission_doppler_resolution/t007a.inp"
1918    );
1919
1920    /// Build a SAMMY input string with a non-TRANSMISSION observation
1921    /// type for the error-path rejection tests.  Replaces the
1922    /// standalone observation-type line only (the standalone match on
1923    /// `\nTRANSMISSION\n` avoids the false match on the title line
1924    /// `FE TRANSMISSION FROM ...`).
1925    fn tr007_with_observation_type(obs_type: &str) -> String {
1926        FE56_TR007_INP.replace("\nTRANSMISSION\n", &format!("\n{obs_type}\n"))
1927    }
1928
1929    #[test]
1930    fn test_parse_plt_header_and_data() {
1931        let content = "\
1932      Energy            Data      Uncertainty     Th_initial      Th_final
1933   1.1330168       8.9078373      0.32005507       8.4964191       8.5014004
1934   1.1336480       8.5677882      0.31321707       8.5003345       8.5048604
1935";
1936        let records = parse_sammy_plt(content).unwrap();
1937        assert_eq!(records.len(), 2);
1938        assert!((records[0].energy_kev - 1.1330168).abs() < 1e-7);
1939        assert!((records[0].theory_initial - 8.4964191).abs() < 1e-5);
1940        assert!((records[1].energy_kev - 1.1336480).abs() < 1e-7);
1941    }
1942
1943    #[test]
1944    fn test_parse_par_tr007() {
1945        let content = "\
1946-2070.     1450.      186600.    0.         0.          0 0 0 0 0 1
194727650.     1400.      1480000.   0.         0.          1 0 1 0 0 1
19481151.07    600.       62.        0.         0.          1 1 1 0 0 2
1949
1950
1951EXPLICIT
1952";
1953        let par = parse_sammy_par(content).unwrap();
1954        assert_eq!(par.resonances.len(), 3);
1955
1956        // First resonance: bound state at -2070 eV.
1957        let r0 = &par.resonances[0];
1958        assert!((r0.energy_ev - (-2070.0)).abs() < 1e-6);
1959        assert!((r0.gamma_gamma_ev - 1.45).abs() < 1e-6); // 1450 meV → 1.45 eV
1960        assert!((r0.gamma_n_ev - 186.6).abs() < 1e-6); // 186600 meV → 186.6 eV
1961        assert_eq!(r0.spin_group, 1);
1962
1963        // Third resonance: 1151.07 eV (in-range for tr007).
1964        let r2 = &par.resonances[2];
1965        assert!((r2.energy_ev - 1151.07).abs() < 1e-6);
1966        assert!((r2.gamma_gamma_ev - 0.6).abs() < 1e-6); // 600 meV → 0.6 eV
1967        assert!((r2.gamma_n_ev - 0.062).abs() < 1e-6); // 62 meV → 0.062 eV
1968        assert_eq!(r2.spin_group, 2);
1969    }
1970
1971    #[test]
1972    fn test_parse_inp_tr007() {
1973        let inp = parse_sammy_inp(FE56_TR007_INP).unwrap();
1974        assert_eq!(inp.isotope_symbol, "FE56");
1975        assert!((inp.awr - 55.9).abs() < 1e-6);
1976        assert!((inp.energy_min_ev - 1133.01).abs() < 1e-6);
1977        assert!((inp.energy_max_ev - 1170.517).abs() < 1e-6);
1978        assert!((inp.temperature_k - 329.0).abs() < 1e-6);
1979        assert!((inp.flight_path_m - 80.263).abs() < 1e-6);
1980        // Card 5 resolution params.
1981        assert!((inp.delta_l_sammy - 0.0301).abs() < 1e-6);
1982        assert!((inp.delta_e_sammy - 0.0).abs() < 1e-6);
1983        assert!((inp.delta_g_sammy - 0.021994).abs() < 1e-6);
1984        // BROADENING card overrides.
1985        assert_eq!(inp.broadening_delta_l, Some(0.025));
1986        assert_eq!(inp.broadening_delta_g, Some(0.022));
1987        assert_eq!(inp.broadening_delta_e, Some(0.022));
1988        // Effective values use BROADENING card when present.
1989        assert!((inp.effective_delta_l() - 0.025).abs() < 1e-6);
1990        assert!((inp.effective_delta_g() - 0.022).abs() < 1e-6);
1991        assert!((inp.effective_delta_e() - 0.022).abs() < 1e-6);
1992        assert!((inp.scattering_radius_fm - 6.0).abs() < 1e-6);
1993        assert!((inp.thickness_atoms_barn - 0.2179).abs() < 1e-6);
1994        // Target spin parsed from spin group header field 6 (".0" → 0.0).
1995        assert!((inp.target_spin - 0.0).abs() < 1e-6);
1996
1997        // Spin groups.
1998        assert_eq!(inp.spin_groups.len(), 2);
1999        assert_eq!(inp.spin_groups[0].index, 1);
2000        assert!((inp.spin_groups[0].j - 0.5).abs() < 1e-6);
2001        assert_eq!(inp.spin_groups[0].l, 0);
2002        assert_eq!(inp.spin_groups[1].index, 2);
2003        assert!((inp.spin_groups[1].j - (-0.5)).abs() < 1e-6); // SAMMY sign preserved
2004        // SG2 has J=-0.5 which couples as L=1, s=1/2, J=|L-S|=1/2.
2005        // SAMMY Card 10.1 stores L at column 18-19, which is field[3] in
2006        // whitespace split (field[2] is ishift=0, not L).
2007        assert_eq!(inp.spin_groups[1].l, 1);
2008    }
2009
2010    #[test]
2011    fn test_parse_isotope_symbol() {
2012        assert_eq!(parse_isotope_symbol("60NI").unwrap(), (28, 60));
2013        assert_eq!(parse_isotope_symbol("FE56").unwrap(), (26, 56));
2014        assert_eq!(parse_isotope_symbol("58NI").unwrap(), (28, 58));
2015        assert!(parse_isotope_symbol("UNKNOWN99").is_err());
2016        // Multi-isotope labels.
2017        assert_eq!(parse_isotope_symbol("Cu65").unwrap(), (29, 65));
2018        assert_eq!(parse_isotope_symbol("Cu63").unwrap(), (29, 63));
2019        assert_eq!(parse_isotope_symbol("CU 65").unwrap(), (29, 65));
2020        // Natural-element prefix.
2021        assert_eq!(parse_isotope_symbol("NatFE").unwrap(), (26, 0));
2022        assert_eq!(parse_isotope_symbol("NatFe").unwrap(), (26, 0));
2023    }
2024
2025    #[test]
2026    fn test_sammy_to_resonance_data_tr007() {
2027        let inp = SammyInpConfig {
2028            title: "test".to_string(),
2029            isotope_symbol: "FE56".to_string(),
2030            awr: 55.9,
2031            energy_min_ev: 1133.01,
2032            energy_max_ev: 1170.517,
2033            temperature_k: 329.0,
2034            flight_path_m: 80.263,
2035            delta_l_sammy: 0.0301,
2036            delta_e_sammy: 0.0,
2037            delta_g_sammy: 0.021994,
2038            no_broadening: false,
2039            broadening_delta_l: None,
2040            broadening_delta_g: None,
2041            broadening_delta_e: None,
2042            scattering_radius_fm: 6.0,
2043            thickness_atoms_barn: 0.2179,
2044            target_spin: 0.0,
2045            observation_type: SammyObservationType::default(),
2046            spin_groups: vec![
2047                SammySpinGroup {
2048                    index: 1,
2049                    j: 0.5,
2050                    l: 0,
2051                    abundance: 0.9999,
2052                    target_spin: 0.0,
2053                    isotope_label: None,
2054                },
2055                SammySpinGroup {
2056                    index: 2,
2057                    j: -0.5, // Negative J per SAMMY convention
2058                    l: 1,    // p-wave: J=|L-S|=|1-0.5|=0.5
2059                    abundance: 0.9172,
2060                    target_spin: 0.0,
2061                    isotope_label: None,
2062                },
2063            ],
2064        };
2065        let par = SammyParFile {
2066            resonances: vec![
2067                SammyResonance {
2068                    energy_ev: -2070.0,
2069                    gamma_gamma_ev: 1.45,
2070                    gamma_n_ev: 186.6,
2071                    gamma_f1_ev: 0.0,
2072                    gamma_f2_ev: 0.0,
2073                    spin_group: 1,
2074                },
2075                SammyResonance {
2076                    energy_ev: 1151.07,
2077                    gamma_gamma_ev: 0.6,
2078                    gamma_n_ev: 0.062,
2079                    gamma_f1_ev: 0.0,
2080                    gamma_f2_ev: 0.0,
2081                    spin_group: 2,
2082                },
2083            ],
2084            radius_overrides: std::collections::HashMap::new(),
2085            r_external: std::collections::HashMap::new(),
2086            isotopic_masses: Vec::new(),
2087        };
2088        let rd = sammy_to_resonance_data(&inp, &par).unwrap();
2089        assert_eq!(rd.za, 26056); // Fe-56
2090        assert_eq!(rd.ranges.len(), 1);
2091        assert_eq!(rd.ranges[0].formalism, ResonanceFormalism::ReichMoore);
2092        assert!((rd.ranges[0].scattering_radius - 6.0).abs() < 1e-6);
2093        // SG1 (L=0) has 1 resonance, SG2 (L=1) has 1 resonance → two L-groups.
2094        assert_eq!(rd.ranges[0].l_groups.len(), 2);
2095        // L-groups are ordered by L (BTreeMap).
2096        assert_eq!(rd.ranges[0].l_groups[0].l, 0);
2097        assert_eq!(rd.ranges[0].l_groups[0].resonances.len(), 1);
2098        assert_eq!(rd.ranges[0].l_groups[1].l, 1);
2099        assert_eq!(rd.ranges[0].l_groups[1].resonances.len(), 1);
2100        // Resonances preserve signed J from their spin groups.
2101        let res_l0 = &rd.ranges[0].l_groups[0].resonances[0];
2102        let res_l1 = &rd.ranges[0].l_groups[1].resonances[0];
2103        assert!((res_l0.j - 0.5).abs() < 1e-6, "spin group 1 → J=+0.5");
2104        assert!((res_l1.j - (-0.5)).abs() < 1e-6, "spin group 2 → J=-0.5");
2105    }
2106
2107    #[test]
2108    fn test_sammy_to_nereids_resolution_conversion() {
2109        // Use tr007's effective BROADENING card values: Deltag=0.022, Deltal=0.025.
2110        let inp = SammyInpConfig {
2111            title: String::new(),
2112            isotope_symbol: "FE56".to_string(),
2113            awr: 55.9,
2114            energy_min_ev: 1133.0,
2115            energy_max_ev: 1170.0,
2116            temperature_k: 329.0,
2117            flight_path_m: 80.263,
2118            delta_l_sammy: 0.0301,
2119            delta_e_sammy: 0.0,
2120            delta_g_sammy: 0.021994,
2121            no_broadening: false,
2122            broadening_delta_l: Some(0.025),
2123            broadening_delta_g: Some(0.022),
2124            broadening_delta_e: Some(0.022),
2125            scattering_radius_fm: 6.0,
2126            thickness_atoms_barn: 0.2179,
2127            target_spin: 0.0,
2128            observation_type: SammyObservationType::default(),
2129            spin_groups: vec![],
2130        };
2131
2132        let (flight_path, delta_t, delta_l, delta_e) =
2133            sammy_to_nereids_resolution(&inp).expect("should return Some for non-zero params");
2134
2135        assert!((flight_path - 80.263).abs() < 1e-10);
2136        // delta_t = Deltag / (2·√ln2) = 0.022 / (2·0.83255...) = 0.01321...
2137        let expected_dt = 0.022 / (2.0 * 2.0_f64.ln().sqrt());
2138        assert!(
2139            (delta_t - expected_dt).abs() < 1e-12,
2140            "delta_t={delta_t}, expected={expected_dt}"
2141        );
2142        // delta_l = Deltal / √6 = 0.025 / 2.44949... = 0.01021...
2143        let expected_dl = 0.025 / 6.0_f64.sqrt();
2144        assert!(
2145            (delta_l - expected_dl).abs() < 1e-12,
2146            "delta_l={delta_l}, expected={expected_dl}"
2147        );
2148        // delta_e = raw Deltae from BROADENING card (no conversion)
2149        assert!(
2150            (delta_e - 0.022).abs() < 1e-12,
2151            "delta_e={delta_e}, expected=0.022"
2152        );
2153    }
2154
2155    #[test]
2156    fn test_sammy_to_nereids_resolution_zero_params() {
2157        let inp = SammyInpConfig {
2158            title: String::new(),
2159            isotope_symbol: "FE56".to_string(),
2160            awr: 55.9,
2161            energy_min_ev: 1133.0,
2162            energy_max_ev: 1170.0,
2163            temperature_k: 329.0,
2164            flight_path_m: 80.263,
2165            delta_l_sammy: 0.0,
2166            delta_e_sammy: 0.0,
2167            delta_g_sammy: 0.0,
2168            no_broadening: false,
2169            broadening_delta_l: None,
2170            broadening_delta_g: None,
2171            broadening_delta_e: None,
2172            scattering_radius_fm: 6.0,
2173            thickness_atoms_barn: 0.2179,
2174            target_spin: 0.0,
2175            observation_type: SammyObservationType::default(),
2176            spin_groups: vec![],
2177        };
2178
2179        assert!(
2180            sammy_to_nereids_resolution(&inp).is_none(),
2181            "should return None when both Deltal and Deltag are zero"
2182        );
2183    }
2184
2185    #[test]
2186    fn test_parse_fortran_d_exponent() {
2187        assert!((parse_fortran_float("1.23D-5").unwrap() - 1.23e-5).abs() < 1e-20);
2188        assert!((parse_fortran_float("1.23d-5").unwrap() - 1.23e-5).abs() < 1e-20);
2189        assert!((parse_fortran_float("5.4D+3").unwrap() - 5.4e3).abs() < 1e-6);
2190        assert!((parse_fortran_float("1.0D0").unwrap() - 1.0).abs() < 1e-15);
2191    }
2192
2193    /// Verify that CAPTURE observation type is rejected with an error.
2194    #[test]
2195    fn test_capture_observation_type_rejected() {
2196        let content = tr007_with_observation_type("CAPTURE CROSS SECTION");
2197        let result = parse_sammy_inp(&content);
2198        assert!(result.is_err(), "CAPTURE should be rejected");
2199        let msg = result.unwrap_err().message;
2200        assert!(
2201            msg.contains("CAPTURE"),
2202            "Error message should mention CAPTURE, got: {msg}"
2203        );
2204    }
2205
2206    /// Verify that DIRAC observation type is rejected with an error.
2207    #[test]
2208    fn test_dirac_observation_type_rejected() {
2209        let content = tr007_with_observation_type("DIRAC CROSS SECTION");
2210        let result = parse_sammy_inp(&content);
2211        assert!(result.is_err(), "DIRAC should be rejected");
2212        let msg = result.unwrap_err().message;
2213        assert!(
2214            msg.contains("DIRAC"),
2215            "Error message should mention DIRAC, got: {msg}"
2216        );
2217    }
2218}