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    let range = ResonanceRange {
1326        energy_low: 1e-5,
1327        energy_high: 2e7,
1328        resolved: true,
1329        formalism: ResonanceFormalism::ReichMoore,
1330        target_spin,
1331        scattering_radius: inp.scattering_radius_fm,
1332        // SAMMY always uses AP for penetrability (equivalent to ENDF NAPS=1).
1333        naps: 1,
1334        ap_table: None,
1335        l_groups,
1336        rml: None,
1337        urr: None,
1338        r_external: vec![],
1339    };
1340
1341    Ok(ResonanceData {
1342        isotope: Isotope::new(z, a)
1343            .map_err(|e| SammyParseError::new(format!("invalid isotope: {e}")))?,
1344        za,
1345        awr: inp.awr,
1346        ranges: vec![range],
1347    })
1348}
1349
1350/// Convert parsed SAMMY `.par` + `.inp` into multiple `(ResonanceData, abundance)` pairs.
1351///
1352/// Groups spin groups into "pseudo-isotopes" by isotope label (if present) or
1353/// by (abundance, target_spin).  Each group gets its own `ResonanceData` with
1354/// only the resonances belonging to that group's spin groups.
1355///
1356/// This is needed for multi-isotope SAMMY cases like tr024 (NatFe: Fe-56 +
1357/// Fe-54 + Fe-57) and tr034 (Cu-65 + Cu-63).
1358pub fn sammy_to_resonance_data_multi(
1359    inp: &SammyInpConfig,
1360    par: &SammyParFile,
1361) -> Result<Vec<(ResonanceData, f64)>, SammyParseError> {
1362    if inp.spin_groups.is_empty() {
1363        return Err(SammyParseError::new("no spin groups defined in .inp file"));
1364    }
1365
1366    // Determine grouping key for each spin group.
1367    // If ANY spin group has an isotope_label, group by label.
1368    // Otherwise, group by (abundance, target_spin) using a string key.
1369    let has_labels = inp.spin_groups.iter().any(|sg| sg.isotope_label.is_some());
1370
1371    // Build grouping: key → (spin_group_indices, abundance, target_spin).
1372    let mut group_order: Vec<String> = Vec::new();
1373    let mut group_members: std::collections::HashMap<String, Vec<usize>> =
1374        std::collections::HashMap::new();
1375    let mut group_abundance: std::collections::HashMap<String, f64> =
1376        std::collections::HashMap::new();
1377    let mut group_target_spin: std::collections::HashMap<String, f64> =
1378        std::collections::HashMap::new();
1379
1380    for (i, sg) in inp.spin_groups.iter().enumerate() {
1381        let key = if has_labels {
1382            sg.isotope_label
1383                .as_deref()
1384                .unwrap_or("unknown")
1385                .to_uppercase()
1386        } else {
1387            format!("{:.6}_{:.1}", sg.abundance, sg.target_spin)
1388        };
1389
1390        if !group_members.contains_key(&key) {
1391            group_order.push(key.clone());
1392        }
1393        group_members.entry(key.clone()).or_default().push(i);
1394        if let Some(&existing) = group_abundance.get(&key)
1395            && (existing - sg.abundance).abs() >= 1e-12
1396        {
1397            return Err(SammyParseError::new(format!(
1398                "spin groups in isotope group '{}' disagree on abundance: {} vs {}",
1399                key, existing, sg.abundance
1400            )));
1401        }
1402        group_abundance.insert(key.clone(), sg.abundance);
1403        if let Some(&existing) = group_target_spin.get(&key)
1404            && (existing - sg.target_spin).abs() >= 1e-12
1405        {
1406            return Err(SammyParseError::new(format!(
1407                "spin groups in isotope group '{}' disagree on target_spin: {} vs {}",
1408                key, existing, sg.target_spin
1409            )));
1410        }
1411        group_target_spin.insert(key.clone(), sg.target_spin);
1412    }
1413
1414    // Build spin_group_index → SammySpinGroup map.
1415    let sg_map: std::collections::HashMap<u32, &SammySpinGroup> =
1416        inp.spin_groups.iter().map(|sg| (sg.index, sg)).collect();
1417
1418    // Build spin_group → (awr, abundance) from ISOTOPIC MASSES section if present.
1419    // These override the .inp spin group abundances and provide per-isotope AWR.
1420    let mut sg_iso_awr: std::collections::HashMap<u32, f64> = std::collections::HashMap::new();
1421    let mut sg_iso_abund: std::collections::HashMap<u32, f64> = std::collections::HashMap::new();
1422    for im in &par.isotopic_masses {
1423        for &sg_idx in &im.spin_groups {
1424            sg_iso_awr.insert(sg_idx, im.awr);
1425            sg_iso_abund.insert(sg_idx, im.abundance);
1426        }
1427    }
1428
1429    // Z from Card 2 (shared across all groups).
1430    let (card2_z, card2_a) = parse_isotope_symbol(&inp.isotope_symbol)?;
1431
1432    let mut result = Vec::new();
1433
1434    for key in &group_order {
1435        let member_indices = &group_members[key];
1436        let target_spin = group_target_spin[key];
1437
1438        // Use ISOTOPIC MASSES abundance when available (overrides .inp spin
1439        // group headers).  Fall back to .inp abundance.
1440        let first_sg_idx = inp.spin_groups[member_indices[0]].index;
1441        let abundance = sg_iso_abund
1442            .get(&first_sg_idx)
1443            .copied()
1444            .unwrap_or(group_abundance[key]);
1445
1446        // Collect the spin group indices (1-based) for this group.
1447        let sg_indices: std::collections::HashSet<u32> = member_indices
1448            .iter()
1449            .map(|&i| inp.spin_groups[i].index)
1450            .collect();
1451
1452        // Filter resonances belonging to this group.
1453        let group_resonances: Vec<&SammyResonance> = par
1454            .resonances
1455            .iter()
1456            .filter(|r| sg_indices.contains(&r.spin_group))
1457            .collect();
1458
1459        // Multi-channel J offset for this isotope group's spin groups.
1460        // See `compute_j_offsets` doc comment for full explanation.
1461        let member_sgs: Vec<SammySpinGroup> = member_indices
1462            .iter()
1463            .map(|&i| inp.spin_groups[i].clone())
1464            .collect();
1465        let sg_j_offset = compute_j_offsets(&member_sgs, target_spin);
1466
1467        // Build L-groups from these resonances.
1468        let mut l_group_map: std::collections::BTreeMap<u32, Vec<Resonance>> =
1469            std::collections::BTreeMap::new();
1470
1471        for res in &group_resonances {
1472            let sg = sg_map.get(&res.spin_group).ok_or_else(|| {
1473                SammyParseError::new(format!(
1474                    "resonance at E={} eV references undefined spin group {}",
1475                    res.energy_ev, res.spin_group
1476                ))
1477            })?;
1478
1479            let j = sg.j + sg_j_offset.get(&sg.index).copied().unwrap_or(0.0);
1480            l_group_map.entry(sg.l).or_default().push(Resonance {
1481                energy: res.energy_ev,
1482                j,
1483                gn: res.gamma_n_ev,
1484                gg: res.gamma_gamma_ev,
1485                gfa: res.gamma_f1_ev,
1486                gfb: res.gamma_f2_ev,
1487            });
1488        }
1489
1490        // Inject zero-width sentinels for spin groups in this isotope
1491        // group that have no resonances (same fix as sammy_to_resonance_data).
1492        let res_sg_indices: std::collections::HashSet<u32> =
1493            group_resonances.iter().map(|r| r.spin_group).collect();
1494        for &idx in &sg_indices {
1495            if !res_sg_indices.contains(&idx)
1496                && let Some(sg) = sg_map.get(&idx)
1497            {
1498                let j = sg.j + sg_j_offset.get(&sg.index).copied().unwrap_or(0.0);
1499                l_group_map.entry(sg.l).or_default().push(Resonance {
1500                    energy: 0.0,
1501                    j,
1502                    gn: 0.0,
1503                    gg: 0.0,
1504                    gfa: 0.0,
1505                    gfb: 0.0,
1506                });
1507            }
1508        }
1509
1510        // Build per-L radius map for this isotope group's spin groups.
1511        let mut l_radius_map: std::collections::HashMap<u32, f64> =
1512            std::collections::HashMap::new();
1513        for &idx in &sg_indices {
1514            if let Some(sg) = sg_map.get(&idx)
1515                && let Some(&r) = par.radius_overrides.get(&sg.index)
1516            {
1517                l_radius_map.entry(sg.l).or_insert(r);
1518            }
1519        }
1520
1521        // Per-isotope AWR: use ISOTOPIC MASSES value when available,
1522        // fall back to .inp global AWR from Card 2.
1523        let isotope_awr = sg_iso_awr.get(&first_sg_idx).copied().unwrap_or(inp.awr);
1524
1525        let l_groups: Vec<LGroup> = l_group_map
1526            .into_iter()
1527            .map(|(l, resonances)| {
1528                let apl = l_radius_map.get(&l).copied().unwrap_or(0.0);
1529                LGroup {
1530                    l,
1531                    awr: isotope_awr,
1532                    apl,
1533                    qx: 0.0,
1534                    lrx: 0,
1535                    resonances,
1536                }
1537            })
1538            .collect();
1539
1540        // Determine Z/A for this group.
1541        let (z, mut a) = if has_labels {
1542            // Try to parse the isotope label (e.g. "Cu65" → Z=29, A=65).
1543            parse_isotope_symbol(key).unwrap_or((card2_z, card2_a))
1544        } else {
1545            (card2_z, card2_a)
1546        };
1547        // For natural-element symbols (A=0), approximate A from AWR.
1548        if a == 0 {
1549            a = isotope_awr.round() as u32;
1550        }
1551        let za = z * 1000 + a;
1552
1553        // Build R-external entries for this isotope group's spin groups.
1554        let mut r_external_entries = Vec::new();
1555        for &idx in &sg_indices {
1556            if let Some(sg) = sg_map.get(&idx)
1557                && let Some(params) = par.r_external.get(&sg.index)
1558            {
1559                let j = sg.j + sg_j_offset.get(&sg.index).copied().unwrap_or(0.0);
1560                r_external_entries.push(RExternalEntry {
1561                    l: sg.l,
1562                    j,
1563                    e_low: params[0],
1564                    e_up: params[1],
1565                    r_con: params[2],
1566                    r_lin: params[3],
1567                    s_con: params[4],
1568                    s_lin: params[5],
1569                    r_quad: params[6],
1570                });
1571            }
1572        }
1573
1574        let range = ResonanceRange {
1575            energy_low: 1e-5,
1576            energy_high: 2e7,
1577            resolved: true,
1578            formalism: ResonanceFormalism::ReichMoore,
1579            target_spin,
1580            scattering_radius: inp.scattering_radius_fm,
1581            // SAMMY always uses AP for penetrability (equivalent to ENDF NAPS=1).
1582            naps: 1,
1583            ap_table: None,
1584            l_groups,
1585            rml: None,
1586            urr: None,
1587            r_external: r_external_entries,
1588        };
1589
1590        let resonance_data = ResonanceData {
1591            isotope: Isotope::new(z, a)
1592                .map_err(|e| SammyParseError::new(format!("invalid isotope: {e}")))?,
1593            za,
1594            awr: isotope_awr,
1595            ranges: vec![range],
1596        };
1597
1598        result.push((resonance_data, abundance));
1599    }
1600
1601    Ok(result)
1602}
1603
1604/// Parse a SAMMY isotope symbol like "60NI", "FE56", "58NI", "NatFE",
1605/// "CU 65", "Cu65" into (Z, A).
1606///
1607/// For natural-element symbols like "NatFE", A=0 is returned (no specific
1608/// mass number).  Space-separated labels like "CU 65" are handled by
1609/// stripping internal spaces.
1610pub(crate) fn parse_isotope_symbol(symbol: &str) -> Result<(u32, u32), SammyParseError> {
1611    // Strip internal spaces and hyphens (handles "CU 65" → "CU65", "Rh-103" → "RH103").
1612    let joined: String = symbol
1613        .chars()
1614        .filter(|c| !c.is_whitespace() && *c != '-')
1615        .collect();
1616    let s = joined.to_uppercase();
1617
1618    // Handle "NAT" prefix: "NATFE" → Z from element, A=0.
1619    if let Some(rest) = s.strip_prefix("NAT")
1620        && let Some(z) = element_symbol_to_z(rest)
1621    {
1622        return Ok((z, 0));
1623    }
1624
1625    // Try format: "60NI" (mass number first, then element symbol).
1626    // Extract just the leading alphabetic chars as element symbol to handle
1627    // labels with trailing comments (e.g. "94Zr ... FAKES" → "ZR").
1628    if let Some(split_pos) = s.find(|c: char| c.is_ascii_alphabetic()) {
1629        let mass_str = &s[..split_pos];
1630        let remaining = &s[split_pos..];
1631        let alpha_len = remaining
1632            .find(|c: char| !c.is_ascii_alphabetic())
1633            .unwrap_or(remaining.len());
1634        let elem_str = &remaining[..alpha_len];
1635        if !mass_str.is_empty()
1636            && let Ok(a) = mass_str.parse::<u32>()
1637            && let Some(z) = element_symbol_to_z(elem_str)
1638        {
1639            return Ok((z, a));
1640        }
1641    }
1642
1643    // Try format: "FE56" (element symbol first, then mass number).
1644    // Extract just the leading digit chars to handle trailing garbage.
1645    if let Some(split_pos) = s.find(|c: char| c.is_ascii_digit()) {
1646        let elem_str = &s[..split_pos];
1647        let remaining = &s[split_pos..];
1648        let digit_len = remaining
1649            .find(|c: char| !c.is_ascii_digit())
1650            .unwrap_or(remaining.len());
1651        let mass_str = &remaining[..digit_len];
1652        if let Ok(a) = mass_str.parse::<u32>()
1653            && let Some(z) = element_symbol_to_z(elem_str)
1654        {
1655            return Ok((z, a));
1656        }
1657    }
1658
1659    // Try bare element symbol without mass number (e.g. "Al" → Z=13, A=0).
1660    if let Some(z) = element_symbol_to_z(&s) {
1661        return Ok((z, 0));
1662    }
1663
1664    // Try full element name (e.g. "ZIRCONIUM" → Z=40, A=0 natural).
1665    if let Some(z) = element_name_to_z(&s) {
1666        return Ok((z, 0));
1667    }
1668
1669    Err(SammyParseError::new(format!(
1670        "cannot parse isotope symbol: {symbol}"
1671    )))
1672}
1673
1674/// Map element symbol (uppercase) to atomic number Z.
1675///
1676/// Covers all elements Z=1 (H) through Z=100 (Fm).
1677fn element_symbol_to_z(symbol: &str) -> Option<u32> {
1678    match symbol {
1679        "H" => Some(1),
1680        "HE" => Some(2),
1681        "LI" => Some(3),
1682        "BE" => Some(4),
1683        "B" => Some(5),
1684        "C" => Some(6),
1685        "N" => Some(7),
1686        "O" => Some(8),
1687        "F" => Some(9),
1688        "NE" => Some(10),
1689        "NA" => Some(11),
1690        "MG" => Some(12),
1691        "AL" => Some(13),
1692        "SI" => Some(14),
1693        "P" => Some(15),
1694        "S" => Some(16),
1695        "CL" => Some(17),
1696        "AR" => Some(18),
1697        "K" => Some(19),
1698        "CA" => Some(20),
1699        "SC" => Some(21),
1700        "TI" => Some(22),
1701        "V" => Some(23),
1702        "CR" => Some(24),
1703        "MN" => Some(25),
1704        "FE" => Some(26),
1705        "CO" => Some(27),
1706        "NI" => Some(28),
1707        "CU" => Some(29),
1708        "ZN" => Some(30),
1709        "GA" => Some(31),
1710        "GE" => Some(32),
1711        "AS" => Some(33),
1712        "SE" => Some(34),
1713        "BR" => Some(35),
1714        "KR" => Some(36),
1715        "RB" => Some(37),
1716        "SR" => Some(38),
1717        "Y" => Some(39),
1718        "ZR" => Some(40),
1719        "NB" => Some(41),
1720        "MO" => Some(42),
1721        "TC" => Some(43),
1722        "RU" => Some(44),
1723        "RH" => Some(45),
1724        "PD" => Some(46),
1725        "AG" => Some(47),
1726        "CD" => Some(48),
1727        "IN" => Some(49),
1728        "SN" => Some(50),
1729        "SB" => Some(51),
1730        "TE" => Some(52),
1731        "I" => Some(53),
1732        "XE" => Some(54),
1733        "CS" => Some(55),
1734        "BA" => Some(56),
1735        "LA" => Some(57),
1736        "CE" => Some(58),
1737        "PR" => Some(59),
1738        "ND" => Some(60),
1739        "PM" => Some(61),
1740        "SM" => Some(62),
1741        "EU" => Some(63),
1742        "GD" => Some(64),
1743        "TB" => Some(65),
1744        "DY" => Some(66),
1745        "HO" => Some(67),
1746        "ER" => Some(68),
1747        "TM" => Some(69),
1748        "YB" => Some(70),
1749        "LU" => Some(71),
1750        "HF" => Some(72),
1751        "TA" => Some(73),
1752        "W" => Some(74),
1753        "RE" => Some(75),
1754        "OS" => Some(76),
1755        "IR" => Some(77),
1756        "PT" => Some(78),
1757        "AU" => Some(79),
1758        "HG" => Some(80),
1759        "TL" => Some(81),
1760        "PB" => Some(82),
1761        "BI" => Some(83),
1762        "PO" => Some(84),
1763        "AT" => Some(85),
1764        "RN" => Some(86),
1765        "FR" => Some(87),
1766        "RA" => Some(88),
1767        "AC" => Some(89),
1768        "TH" => Some(90),
1769        "PA" => Some(91),
1770        "U" => Some(92),
1771        "NP" => Some(93),
1772        "PU" => Some(94),
1773        "AM" => Some(95),
1774        "CM" => Some(96),
1775        "BK" => Some(97),
1776        "CF" => Some(98),
1777        "ES" => Some(99),
1778        "FM" => Some(100),
1779        _ => None,
1780    }
1781}
1782
1783/// Map full element name (uppercase) to atomic number Z.
1784///
1785/// Handles SAMMY inp files that use full element names on Card 2
1786/// (e.g. "ZIRCONIUM" instead of "ZR").
1787fn element_name_to_z(name: &str) -> Option<u32> {
1788    match name {
1789        "HYDROGEN" => Some(1),
1790        "HELIUM" => Some(2),
1791        "LITHIUM" => Some(3),
1792        "BERYLLIUM" => Some(4),
1793        "BORON" => Some(5),
1794        "CARBON" => Some(6),
1795        "NITROGEN" => Some(7),
1796        "OXYGEN" => Some(8),
1797        "FLUORINE" => Some(9),
1798        "NEON" => Some(10),
1799        "SODIUM" => Some(11),
1800        "MAGNESIUM" => Some(12),
1801        "ALUMINUM" | "ALUMINIUM" => Some(13),
1802        "SILICON" => Some(14),
1803        "PHOSPHORUS" => Some(15),
1804        "SULFUR" | "SULPHUR" => Some(16),
1805        "CHLORINE" => Some(17),
1806        "ARGON" => Some(18),
1807        "POTASSIUM" => Some(19),
1808        "CALCIUM" => Some(20),
1809        "SCANDIUM" => Some(21),
1810        "TITANIUM" => Some(22),
1811        "VANADIUM" => Some(23),
1812        "CHROMIUM" => Some(24),
1813        "MANGANESE" => Some(25),
1814        "IRON" => Some(26),
1815        "COBALT" => Some(27),
1816        "NICKEL" => Some(28),
1817        "COPPER" => Some(29),
1818        "ZINC" => Some(30),
1819        "GALLIUM" => Some(31),
1820        "GERMANIUM" => Some(32),
1821        "ARSENIC" => Some(33),
1822        "SELENIUM" => Some(34),
1823        "BROMINE" => Some(35),
1824        "KRYPTON" => Some(36),
1825        "RUBIDIUM" => Some(37),
1826        "STRONTIUM" => Some(38),
1827        "YTTRIUM" => Some(39),
1828        "ZIRCONIUM" => Some(40),
1829        "NIOBIUM" => Some(41),
1830        "MOLYBDENUM" => Some(42),
1831        "TECHNETIUM" => Some(43),
1832        "RUTHENIUM" => Some(44),
1833        "RHODIUM" => Some(45),
1834        "PALLADIUM" => Some(46),
1835        "SILVER" => Some(47),
1836        "CADMIUM" => Some(48),
1837        "INDIUM" => Some(49),
1838        "TIN" => Some(50),
1839        "ANTIMONY" => Some(51),
1840        "TELLURIUM" => Some(52),
1841        "IODINE" => Some(53),
1842        "XENON" => Some(54),
1843        "CESIUM" | "CAESIUM" => Some(55),
1844        "BARIUM" => Some(56),
1845        "LANTHANUM" => Some(57),
1846        "CERIUM" => Some(58),
1847        "PRASEODYMIUM" => Some(59),
1848        "NEODYMIUM" => Some(60),
1849        "PROMETHIUM" => Some(61),
1850        "SAMARIUM" => Some(62),
1851        "EUROPIUM" => Some(63),
1852        "GADOLINIUM" => Some(64),
1853        "TERBIUM" => Some(65),
1854        "DYSPROSIUM" => Some(66),
1855        "HOLMIUM" => Some(67),
1856        "ERBIUM" => Some(68),
1857        "THULIUM" => Some(69),
1858        "YTTERBIUM" => Some(70),
1859        "LUTETIUM" => Some(71),
1860        "HAFNIUM" => Some(72),
1861        "TANTALUM" => Some(73),
1862        "TUNGSTEN" => Some(74),
1863        "RHENIUM" => Some(75),
1864        "OSMIUM" => Some(76),
1865        "IRIDIUM" => Some(77),
1866        "PLATINUM" => Some(78),
1867        "GOLD" => Some(79),
1868        "MERCURY" => Some(80),
1869        "THALLIUM" => Some(81),
1870        "LEAD" => Some(82),
1871        "BISMUTH" => Some(83),
1872        "THORIUM" => Some(90),
1873        "PROTACTINIUM" => Some(91),
1874        "URANIUM" => Some(92),
1875        "NEPTUNIUM" => Some(93),
1876        "PLUTONIUM" => Some(94),
1877        "AMERICIUM" => Some(95),
1878        "CURIUM" => Some(96),
1879        "BERKELIUM" => Some(97),
1880        "CALIFORNIUM" => Some(98),
1881        _ => None,
1882    }
1883}
1884
1885// ─── Tests ─────────────────────────────────────────────────────────────────────
1886
1887#[cfg(test)]
1888mod tests {
1889    use super::*;
1890
1891    /// Canonical SAMMY `.inp` fixture for the Fe-56 tr007 transmission
1892    /// case.  Loaded once and reused by the TRANSMISSION test directly
1893    /// and by the CAPTURE / DIRAC rejection tests via
1894    /// [`tr007_with_observation_type`] which swaps the observation-type
1895    /// line.  Eliminates the prior 3 inline near-duplicates.
1896    const FE56_TR007_INP: &str = include_str!(
1897        "../../../tests/data/samtry/tr007_fe56_transmission_doppler_resolution/t007a.inp"
1898    );
1899
1900    /// Build a SAMMY input string with a non-TRANSMISSION observation
1901    /// type for the error-path rejection tests.  Replaces the
1902    /// standalone observation-type line only (the standalone match on
1903    /// `\nTRANSMISSION\n` avoids the false match on the title line
1904    /// `FE TRANSMISSION FROM ...`).
1905    fn tr007_with_observation_type(obs_type: &str) -> String {
1906        FE56_TR007_INP.replace("\nTRANSMISSION\n", &format!("\n{obs_type}\n"))
1907    }
1908
1909    #[test]
1910    fn test_parse_plt_header_and_data() {
1911        let content = "\
1912      Energy            Data      Uncertainty     Th_initial      Th_final
1913   1.1330168       8.9078373      0.32005507       8.4964191       8.5014004
1914   1.1336480       8.5677882      0.31321707       8.5003345       8.5048604
1915";
1916        let records = parse_sammy_plt(content).unwrap();
1917        assert_eq!(records.len(), 2);
1918        assert!((records[0].energy_kev - 1.1330168).abs() < 1e-7);
1919        assert!((records[0].theory_initial - 8.4964191).abs() < 1e-5);
1920        assert!((records[1].energy_kev - 1.1336480).abs() < 1e-7);
1921    }
1922
1923    #[test]
1924    fn test_parse_par_tr007() {
1925        let content = "\
1926-2070.     1450.      186600.    0.         0.          0 0 0 0 0 1
192727650.     1400.      1480000.   0.         0.          1 0 1 0 0 1
19281151.07    600.       62.        0.         0.          1 1 1 0 0 2
1929
1930
1931EXPLICIT
1932";
1933        let par = parse_sammy_par(content).unwrap();
1934        assert_eq!(par.resonances.len(), 3);
1935
1936        // First resonance: bound state at -2070 eV.
1937        let r0 = &par.resonances[0];
1938        assert!((r0.energy_ev - (-2070.0)).abs() < 1e-6);
1939        assert!((r0.gamma_gamma_ev - 1.45).abs() < 1e-6); // 1450 meV → 1.45 eV
1940        assert!((r0.gamma_n_ev - 186.6).abs() < 1e-6); // 186600 meV → 186.6 eV
1941        assert_eq!(r0.spin_group, 1);
1942
1943        // Third resonance: 1151.07 eV (in-range for tr007).
1944        let r2 = &par.resonances[2];
1945        assert!((r2.energy_ev - 1151.07).abs() < 1e-6);
1946        assert!((r2.gamma_gamma_ev - 0.6).abs() < 1e-6); // 600 meV → 0.6 eV
1947        assert!((r2.gamma_n_ev - 0.062).abs() < 1e-6); // 62 meV → 0.062 eV
1948        assert_eq!(r2.spin_group, 2);
1949    }
1950
1951    #[test]
1952    fn test_parse_inp_tr007() {
1953        let inp = parse_sammy_inp(FE56_TR007_INP).unwrap();
1954        assert_eq!(inp.isotope_symbol, "FE56");
1955        assert!((inp.awr - 55.9).abs() < 1e-6);
1956        assert!((inp.energy_min_ev - 1133.01).abs() < 1e-6);
1957        assert!((inp.energy_max_ev - 1170.517).abs() < 1e-6);
1958        assert!((inp.temperature_k - 329.0).abs() < 1e-6);
1959        assert!((inp.flight_path_m - 80.263).abs() < 1e-6);
1960        // Card 5 resolution params.
1961        assert!((inp.delta_l_sammy - 0.0301).abs() < 1e-6);
1962        assert!((inp.delta_e_sammy - 0.0).abs() < 1e-6);
1963        assert!((inp.delta_g_sammy - 0.021994).abs() < 1e-6);
1964        // BROADENING card overrides.
1965        assert_eq!(inp.broadening_delta_l, Some(0.025));
1966        assert_eq!(inp.broadening_delta_g, Some(0.022));
1967        assert_eq!(inp.broadening_delta_e, Some(0.022));
1968        // Effective values use BROADENING card when present.
1969        assert!((inp.effective_delta_l() - 0.025).abs() < 1e-6);
1970        assert!((inp.effective_delta_g() - 0.022).abs() < 1e-6);
1971        assert!((inp.effective_delta_e() - 0.022).abs() < 1e-6);
1972        assert!((inp.scattering_radius_fm - 6.0).abs() < 1e-6);
1973        assert!((inp.thickness_atoms_barn - 0.2179).abs() < 1e-6);
1974        // Target spin parsed from spin group header field 6 (".0" → 0.0).
1975        assert!((inp.target_spin - 0.0).abs() < 1e-6);
1976
1977        // Spin groups.
1978        assert_eq!(inp.spin_groups.len(), 2);
1979        assert_eq!(inp.spin_groups[0].index, 1);
1980        assert!((inp.spin_groups[0].j - 0.5).abs() < 1e-6);
1981        assert_eq!(inp.spin_groups[0].l, 0);
1982        assert_eq!(inp.spin_groups[1].index, 2);
1983        assert!((inp.spin_groups[1].j - (-0.5)).abs() < 1e-6); // SAMMY sign preserved
1984        // SG2 has J=-0.5 which couples as L=1, s=1/2, J=|L-S|=1/2.
1985        // SAMMY Card 10.1 stores L at column 18-19, which is field[3] in
1986        // whitespace split (field[2] is ishift=0, not L).
1987        assert_eq!(inp.spin_groups[1].l, 1);
1988    }
1989
1990    #[test]
1991    fn test_parse_isotope_symbol() {
1992        assert_eq!(parse_isotope_symbol("60NI").unwrap(), (28, 60));
1993        assert_eq!(parse_isotope_symbol("FE56").unwrap(), (26, 56));
1994        assert_eq!(parse_isotope_symbol("58NI").unwrap(), (28, 58));
1995        assert!(parse_isotope_symbol("UNKNOWN99").is_err());
1996        // Multi-isotope labels.
1997        assert_eq!(parse_isotope_symbol("Cu65").unwrap(), (29, 65));
1998        assert_eq!(parse_isotope_symbol("Cu63").unwrap(), (29, 63));
1999        assert_eq!(parse_isotope_symbol("CU 65").unwrap(), (29, 65));
2000        // Natural-element prefix.
2001        assert_eq!(parse_isotope_symbol("NatFE").unwrap(), (26, 0));
2002        assert_eq!(parse_isotope_symbol("NatFe").unwrap(), (26, 0));
2003    }
2004
2005    #[test]
2006    fn test_sammy_to_resonance_data_tr007() {
2007        let inp = SammyInpConfig {
2008            title: "test".to_string(),
2009            isotope_symbol: "FE56".to_string(),
2010            awr: 55.9,
2011            energy_min_ev: 1133.01,
2012            energy_max_ev: 1170.517,
2013            temperature_k: 329.0,
2014            flight_path_m: 80.263,
2015            delta_l_sammy: 0.0301,
2016            delta_e_sammy: 0.0,
2017            delta_g_sammy: 0.021994,
2018            no_broadening: false,
2019            broadening_delta_l: None,
2020            broadening_delta_g: None,
2021            broadening_delta_e: None,
2022            scattering_radius_fm: 6.0,
2023            thickness_atoms_barn: 0.2179,
2024            target_spin: 0.0,
2025            observation_type: SammyObservationType::default(),
2026            spin_groups: vec![
2027                SammySpinGroup {
2028                    index: 1,
2029                    j: 0.5,
2030                    l: 0,
2031                    abundance: 0.9999,
2032                    target_spin: 0.0,
2033                    isotope_label: None,
2034                },
2035                SammySpinGroup {
2036                    index: 2,
2037                    j: -0.5, // Negative J per SAMMY convention
2038                    l: 1,    // p-wave: J=|L-S|=|1-0.5|=0.5
2039                    abundance: 0.9172,
2040                    target_spin: 0.0,
2041                    isotope_label: None,
2042                },
2043            ],
2044        };
2045        let par = SammyParFile {
2046            resonances: vec![
2047                SammyResonance {
2048                    energy_ev: -2070.0,
2049                    gamma_gamma_ev: 1.45,
2050                    gamma_n_ev: 186.6,
2051                    gamma_f1_ev: 0.0,
2052                    gamma_f2_ev: 0.0,
2053                    spin_group: 1,
2054                },
2055                SammyResonance {
2056                    energy_ev: 1151.07,
2057                    gamma_gamma_ev: 0.6,
2058                    gamma_n_ev: 0.062,
2059                    gamma_f1_ev: 0.0,
2060                    gamma_f2_ev: 0.0,
2061                    spin_group: 2,
2062                },
2063            ],
2064            radius_overrides: std::collections::HashMap::new(),
2065            r_external: std::collections::HashMap::new(),
2066            isotopic_masses: Vec::new(),
2067        };
2068        let rd = sammy_to_resonance_data(&inp, &par).unwrap();
2069        assert_eq!(rd.za, 26056); // Fe-56
2070        assert_eq!(rd.ranges.len(), 1);
2071        assert_eq!(rd.ranges[0].formalism, ResonanceFormalism::ReichMoore);
2072        assert!((rd.ranges[0].scattering_radius - 6.0).abs() < 1e-6);
2073        // SG1 (L=0) has 1 resonance, SG2 (L=1) has 1 resonance → two L-groups.
2074        assert_eq!(rd.ranges[0].l_groups.len(), 2);
2075        // L-groups are ordered by L (BTreeMap).
2076        assert_eq!(rd.ranges[0].l_groups[0].l, 0);
2077        assert_eq!(rd.ranges[0].l_groups[0].resonances.len(), 1);
2078        assert_eq!(rd.ranges[0].l_groups[1].l, 1);
2079        assert_eq!(rd.ranges[0].l_groups[1].resonances.len(), 1);
2080        // Resonances preserve signed J from their spin groups.
2081        let res_l0 = &rd.ranges[0].l_groups[0].resonances[0];
2082        let res_l1 = &rd.ranges[0].l_groups[1].resonances[0];
2083        assert!((res_l0.j - 0.5).abs() < 1e-6, "spin group 1 → J=+0.5");
2084        assert!((res_l1.j - (-0.5)).abs() < 1e-6, "spin group 2 → J=-0.5");
2085    }
2086
2087    #[test]
2088    fn test_sammy_to_nereids_resolution_conversion() {
2089        // Use tr007's effective BROADENING card values: Deltag=0.022, Deltal=0.025.
2090        let inp = SammyInpConfig {
2091            title: String::new(),
2092            isotope_symbol: "FE56".to_string(),
2093            awr: 55.9,
2094            energy_min_ev: 1133.0,
2095            energy_max_ev: 1170.0,
2096            temperature_k: 329.0,
2097            flight_path_m: 80.263,
2098            delta_l_sammy: 0.0301,
2099            delta_e_sammy: 0.0,
2100            delta_g_sammy: 0.021994,
2101            no_broadening: false,
2102            broadening_delta_l: Some(0.025),
2103            broadening_delta_g: Some(0.022),
2104            broadening_delta_e: Some(0.022),
2105            scattering_radius_fm: 6.0,
2106            thickness_atoms_barn: 0.2179,
2107            target_spin: 0.0,
2108            observation_type: SammyObservationType::default(),
2109            spin_groups: vec![],
2110        };
2111
2112        let (flight_path, delta_t, delta_l, delta_e) =
2113            sammy_to_nereids_resolution(&inp).expect("should return Some for non-zero params");
2114
2115        assert!((flight_path - 80.263).abs() < 1e-10);
2116        // delta_t = Deltag / (2·√ln2) = 0.022 / (2·0.83255...) = 0.01321...
2117        let expected_dt = 0.022 / (2.0 * 2.0_f64.ln().sqrt());
2118        assert!(
2119            (delta_t - expected_dt).abs() < 1e-12,
2120            "delta_t={delta_t}, expected={expected_dt}"
2121        );
2122        // delta_l = Deltal / √6 = 0.025 / 2.44949... = 0.01021...
2123        let expected_dl = 0.025 / 6.0_f64.sqrt();
2124        assert!(
2125            (delta_l - expected_dl).abs() < 1e-12,
2126            "delta_l={delta_l}, expected={expected_dl}"
2127        );
2128        // delta_e = raw Deltae from BROADENING card (no conversion)
2129        assert!(
2130            (delta_e - 0.022).abs() < 1e-12,
2131            "delta_e={delta_e}, expected=0.022"
2132        );
2133    }
2134
2135    #[test]
2136    fn test_sammy_to_nereids_resolution_zero_params() {
2137        let inp = SammyInpConfig {
2138            title: String::new(),
2139            isotope_symbol: "FE56".to_string(),
2140            awr: 55.9,
2141            energy_min_ev: 1133.0,
2142            energy_max_ev: 1170.0,
2143            temperature_k: 329.0,
2144            flight_path_m: 80.263,
2145            delta_l_sammy: 0.0,
2146            delta_e_sammy: 0.0,
2147            delta_g_sammy: 0.0,
2148            no_broadening: false,
2149            broadening_delta_l: None,
2150            broadening_delta_g: None,
2151            broadening_delta_e: None,
2152            scattering_radius_fm: 6.0,
2153            thickness_atoms_barn: 0.2179,
2154            target_spin: 0.0,
2155            observation_type: SammyObservationType::default(),
2156            spin_groups: vec![],
2157        };
2158
2159        assert!(
2160            sammy_to_nereids_resolution(&inp).is_none(),
2161            "should return None when both Deltal and Deltag are zero"
2162        );
2163    }
2164
2165    #[test]
2166    fn test_parse_fortran_d_exponent() {
2167        assert!((parse_fortran_float("1.23D-5").unwrap() - 1.23e-5).abs() < 1e-20);
2168        assert!((parse_fortran_float("1.23d-5").unwrap() - 1.23e-5).abs() < 1e-20);
2169        assert!((parse_fortran_float("5.4D+3").unwrap() - 5.4e3).abs() < 1e-6);
2170        assert!((parse_fortran_float("1.0D0").unwrap() - 1.0).abs() < 1e-15);
2171    }
2172
2173    /// Verify that CAPTURE observation type is rejected with an error.
2174    #[test]
2175    fn test_capture_observation_type_rejected() {
2176        let content = tr007_with_observation_type("CAPTURE CROSS SECTION");
2177        let result = parse_sammy_inp(&content);
2178        assert!(result.is_err(), "CAPTURE should be rejected");
2179        let msg = result.unwrap_err().message;
2180        assert!(
2181            msg.contains("CAPTURE"),
2182            "Error message should mention CAPTURE, got: {msg}"
2183        );
2184    }
2185
2186    /// Verify that DIRAC observation type is rejected with an error.
2187    #[test]
2188    fn test_dirac_observation_type_rejected() {
2189        let content = tr007_with_observation_type("DIRAC CROSS SECTION");
2190        let result = parse_sammy_inp(&content);
2191        assert!(result.is_err(), "DIRAC should be rejected");
2192        let msg = result.unwrap_err().message;
2193        assert!(
2194            msg.contains("DIRAC"),
2195            "Error message should mention DIRAC, got: {msg}"
2196        );
2197    }
2198}