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    #[test]
1892    fn test_parse_plt_header_and_data() {
1893        let content = "\
1894      Energy            Data      Uncertainty     Th_initial      Th_final
1895   1.1330168       8.9078373      0.32005507       8.4964191       8.5014004
1896   1.1336480       8.5677882      0.31321707       8.5003345       8.5048604
1897";
1898        let records = parse_sammy_plt(content).unwrap();
1899        assert_eq!(records.len(), 2);
1900        assert!((records[0].energy_kev - 1.1330168).abs() < 1e-7);
1901        assert!((records[0].theory_initial - 8.4964191).abs() < 1e-5);
1902        assert!((records[1].energy_kev - 1.1336480).abs() < 1e-7);
1903    }
1904
1905    #[test]
1906    fn test_parse_par_tr007() {
1907        let content = "\
1908-2070.     1450.      186600.    0.         0.          0 0 0 0 0 1
190927650.     1400.      1480000.   0.         0.          1 0 1 0 0 1
19101151.07    600.       62.        0.         0.          1 1 1 0 0 2
1911
1912
1913EXPLICIT
1914";
1915        let par = parse_sammy_par(content).unwrap();
1916        assert_eq!(par.resonances.len(), 3);
1917
1918        // First resonance: bound state at -2070 eV.
1919        let r0 = &par.resonances[0];
1920        assert!((r0.energy_ev - (-2070.0)).abs() < 1e-6);
1921        assert!((r0.gamma_gamma_ev - 1.45).abs() < 1e-6); // 1450 meV → 1.45 eV
1922        assert!((r0.gamma_n_ev - 186.6).abs() < 1e-6); // 186600 meV → 186.6 eV
1923        assert_eq!(r0.spin_group, 1);
1924
1925        // Third resonance: 1151.07 eV (in-range for tr007).
1926        let r2 = &par.resonances[2];
1927        assert!((r2.energy_ev - 1151.07).abs() < 1e-6);
1928        assert!((r2.gamma_gamma_ev - 0.6).abs() < 1e-6); // 600 meV → 0.6 eV
1929        assert!((r2.gamma_n_ev - 0.062).abs() < 1e-6); // 62 meV → 0.062 eV
1930        assert_eq!(r2.spin_group, 2);
1931    }
1932
1933    #[test]
1934    fn test_parse_inp_tr007() {
1935        let content = "\
1936 FE TRANSMISSION FROM 1.13 TO 1.18 KEV
1937      FE56 55.9      1133.01   1170.517    99
1938PRINT WEIGHTED RESIDUALS
1939DATA COVARIANCE FILE IS NAMED t007a.dcv
1940PRINT THEORETICAL VALUES
1941PRINT ALL INPUT PARAMETERS
1942PRINT INPUT DATA
1943WRITE NEW INPUT FILE = temp1_thick1.inp
1944#DO NOT SUPPRESS ANY INTERMEDIATE RESULTS
1945GENERATE PLOT FILE AUTOMATICALLY
1946
1947329.      80.263     0.0301   0.0       .021994
19486.0       0.2179
1949TRANSMISSION
1950  1      1    0  0.5    0.9999   .0
1951    1    1    0    0      .500      .000      .000
1952  2      1    0 -0.5    0.9172   .0
1953    1    1    0    1      .500      .000      .000
1954
1955BROADENING
19566.00      329.00    0.2179    0.025     0.022     0.022      1 1 1 1 1 1
1957";
1958        let inp = parse_sammy_inp(content).unwrap();
1959        assert_eq!(inp.isotope_symbol, "FE56");
1960        assert!((inp.awr - 55.9).abs() < 1e-6);
1961        assert!((inp.energy_min_ev - 1133.01).abs() < 1e-6);
1962        assert!((inp.energy_max_ev - 1170.517).abs() < 1e-6);
1963        assert!((inp.temperature_k - 329.0).abs() < 1e-6);
1964        assert!((inp.flight_path_m - 80.263).abs() < 1e-6);
1965        // Card 5 resolution params.
1966        assert!((inp.delta_l_sammy - 0.0301).abs() < 1e-6);
1967        assert!((inp.delta_e_sammy - 0.0).abs() < 1e-6);
1968        assert!((inp.delta_g_sammy - 0.021994).abs() < 1e-6);
1969        // BROADENING card overrides.
1970        assert_eq!(inp.broadening_delta_l, Some(0.025));
1971        assert_eq!(inp.broadening_delta_g, Some(0.022));
1972        assert_eq!(inp.broadening_delta_e, Some(0.022));
1973        // Effective values use BROADENING card when present.
1974        assert!((inp.effective_delta_l() - 0.025).abs() < 1e-6);
1975        assert!((inp.effective_delta_g() - 0.022).abs() < 1e-6);
1976        assert!((inp.effective_delta_e() - 0.022).abs() < 1e-6);
1977        assert!((inp.scattering_radius_fm - 6.0).abs() < 1e-6);
1978        assert!((inp.thickness_atoms_barn - 0.2179).abs() < 1e-6);
1979        // Target spin parsed from spin group header field 6 (".0" → 0.0).
1980        assert!((inp.target_spin - 0.0).abs() < 1e-6);
1981
1982        // Spin groups.
1983        assert_eq!(inp.spin_groups.len(), 2);
1984        assert_eq!(inp.spin_groups[0].index, 1);
1985        assert!((inp.spin_groups[0].j - 0.5).abs() < 1e-6);
1986        assert_eq!(inp.spin_groups[0].l, 0);
1987        assert_eq!(inp.spin_groups[1].index, 2);
1988        assert!((inp.spin_groups[1].j - (-0.5)).abs() < 1e-6); // SAMMY sign preserved
1989        // SG2 has J=-0.5 which couples as L=1, s=1/2, J=|L-S|=1/2.
1990        // SAMMY Card 10.1 stores L at column 18-19, which is field[3] in
1991        // whitespace split (field[2] is ishift=0, not L).
1992        assert_eq!(inp.spin_groups[1].l, 1);
1993    }
1994
1995    #[test]
1996    fn test_parse_isotope_symbol() {
1997        assert_eq!(parse_isotope_symbol("60NI").unwrap(), (28, 60));
1998        assert_eq!(parse_isotope_symbol("FE56").unwrap(), (26, 56));
1999        assert_eq!(parse_isotope_symbol("58NI").unwrap(), (28, 58));
2000        assert!(parse_isotope_symbol("UNKNOWN99").is_err());
2001        // Multi-isotope labels.
2002        assert_eq!(parse_isotope_symbol("Cu65").unwrap(), (29, 65));
2003        assert_eq!(parse_isotope_symbol("Cu63").unwrap(), (29, 63));
2004        assert_eq!(parse_isotope_symbol("CU 65").unwrap(), (29, 65));
2005        // Natural-element prefix.
2006        assert_eq!(parse_isotope_symbol("NatFE").unwrap(), (26, 0));
2007        assert_eq!(parse_isotope_symbol("NatFe").unwrap(), (26, 0));
2008    }
2009
2010    #[test]
2011    fn test_sammy_to_resonance_data_tr007() {
2012        let inp = SammyInpConfig {
2013            title: "test".to_string(),
2014            isotope_symbol: "FE56".to_string(),
2015            awr: 55.9,
2016            energy_min_ev: 1133.01,
2017            energy_max_ev: 1170.517,
2018            temperature_k: 329.0,
2019            flight_path_m: 80.263,
2020            delta_l_sammy: 0.0301,
2021            delta_e_sammy: 0.0,
2022            delta_g_sammy: 0.021994,
2023            no_broadening: false,
2024            broadening_delta_l: None,
2025            broadening_delta_g: None,
2026            broadening_delta_e: None,
2027            scattering_radius_fm: 6.0,
2028            thickness_atoms_barn: 0.2179,
2029            target_spin: 0.0,
2030            observation_type: SammyObservationType::default(),
2031            spin_groups: vec![
2032                SammySpinGroup {
2033                    index: 1,
2034                    j: 0.5,
2035                    l: 0,
2036                    abundance: 0.9999,
2037                    target_spin: 0.0,
2038                    isotope_label: None,
2039                },
2040                SammySpinGroup {
2041                    index: 2,
2042                    j: -0.5, // Negative J per SAMMY convention
2043                    l: 1,    // p-wave: J=|L-S|=|1-0.5|=0.5
2044                    abundance: 0.9172,
2045                    target_spin: 0.0,
2046                    isotope_label: None,
2047                },
2048            ],
2049        };
2050        let par = SammyParFile {
2051            resonances: vec![
2052                SammyResonance {
2053                    energy_ev: -2070.0,
2054                    gamma_gamma_ev: 1.45,
2055                    gamma_n_ev: 186.6,
2056                    gamma_f1_ev: 0.0,
2057                    gamma_f2_ev: 0.0,
2058                    spin_group: 1,
2059                },
2060                SammyResonance {
2061                    energy_ev: 1151.07,
2062                    gamma_gamma_ev: 0.6,
2063                    gamma_n_ev: 0.062,
2064                    gamma_f1_ev: 0.0,
2065                    gamma_f2_ev: 0.0,
2066                    spin_group: 2,
2067                },
2068            ],
2069            radius_overrides: std::collections::HashMap::new(),
2070            r_external: std::collections::HashMap::new(),
2071            isotopic_masses: Vec::new(),
2072        };
2073        let rd = sammy_to_resonance_data(&inp, &par).unwrap();
2074        assert_eq!(rd.za, 26056); // Fe-56
2075        assert_eq!(rd.ranges.len(), 1);
2076        assert_eq!(rd.ranges[0].formalism, ResonanceFormalism::ReichMoore);
2077        assert!((rd.ranges[0].scattering_radius - 6.0).abs() < 1e-6);
2078        // SG1 (L=0) has 1 resonance, SG2 (L=1) has 1 resonance → two L-groups.
2079        assert_eq!(rd.ranges[0].l_groups.len(), 2);
2080        // L-groups are ordered by L (BTreeMap).
2081        assert_eq!(rd.ranges[0].l_groups[0].l, 0);
2082        assert_eq!(rd.ranges[0].l_groups[0].resonances.len(), 1);
2083        assert_eq!(rd.ranges[0].l_groups[1].l, 1);
2084        assert_eq!(rd.ranges[0].l_groups[1].resonances.len(), 1);
2085        // Resonances preserve signed J from their spin groups.
2086        let res_l0 = &rd.ranges[0].l_groups[0].resonances[0];
2087        let res_l1 = &rd.ranges[0].l_groups[1].resonances[0];
2088        assert!((res_l0.j - 0.5).abs() < 1e-6, "spin group 1 → J=+0.5");
2089        assert!((res_l1.j - (-0.5)).abs() < 1e-6, "spin group 2 → J=-0.5");
2090    }
2091
2092    #[test]
2093    fn test_sammy_to_nereids_resolution_conversion() {
2094        // Use tr007's effective BROADENING card values: Deltag=0.022, Deltal=0.025.
2095        let inp = SammyInpConfig {
2096            title: String::new(),
2097            isotope_symbol: "FE56".to_string(),
2098            awr: 55.9,
2099            energy_min_ev: 1133.0,
2100            energy_max_ev: 1170.0,
2101            temperature_k: 329.0,
2102            flight_path_m: 80.263,
2103            delta_l_sammy: 0.0301,
2104            delta_e_sammy: 0.0,
2105            delta_g_sammy: 0.021994,
2106            no_broadening: false,
2107            broadening_delta_l: Some(0.025),
2108            broadening_delta_g: Some(0.022),
2109            broadening_delta_e: Some(0.022),
2110            scattering_radius_fm: 6.0,
2111            thickness_atoms_barn: 0.2179,
2112            target_spin: 0.0,
2113            observation_type: SammyObservationType::default(),
2114            spin_groups: vec![],
2115        };
2116
2117        let (flight_path, delta_t, delta_l, delta_e) =
2118            sammy_to_nereids_resolution(&inp).expect("should return Some for non-zero params");
2119
2120        assert!((flight_path - 80.263).abs() < 1e-10);
2121        // delta_t = Deltag / (2·√ln2) = 0.022 / (2·0.83255...) = 0.01321...
2122        let expected_dt = 0.022 / (2.0 * 2.0_f64.ln().sqrt());
2123        assert!(
2124            (delta_t - expected_dt).abs() < 1e-12,
2125            "delta_t={delta_t}, expected={expected_dt}"
2126        );
2127        // delta_l = Deltal / √6 = 0.025 / 2.44949... = 0.01021...
2128        let expected_dl = 0.025 / 6.0_f64.sqrt();
2129        assert!(
2130            (delta_l - expected_dl).abs() < 1e-12,
2131            "delta_l={delta_l}, expected={expected_dl}"
2132        );
2133        // delta_e = raw Deltae from BROADENING card (no conversion)
2134        assert!(
2135            (delta_e - 0.022).abs() < 1e-12,
2136            "delta_e={delta_e}, expected=0.022"
2137        );
2138    }
2139
2140    #[test]
2141    fn test_sammy_to_nereids_resolution_zero_params() {
2142        let inp = SammyInpConfig {
2143            title: String::new(),
2144            isotope_symbol: "FE56".to_string(),
2145            awr: 55.9,
2146            energy_min_ev: 1133.0,
2147            energy_max_ev: 1170.0,
2148            temperature_k: 329.0,
2149            flight_path_m: 80.263,
2150            delta_l_sammy: 0.0,
2151            delta_e_sammy: 0.0,
2152            delta_g_sammy: 0.0,
2153            no_broadening: false,
2154            broadening_delta_l: None,
2155            broadening_delta_g: None,
2156            broadening_delta_e: None,
2157            scattering_radius_fm: 6.0,
2158            thickness_atoms_barn: 0.2179,
2159            target_spin: 0.0,
2160            observation_type: SammyObservationType::default(),
2161            spin_groups: vec![],
2162        };
2163
2164        assert!(
2165            sammy_to_nereids_resolution(&inp).is_none(),
2166            "should return None when both Deltal and Deltag are zero"
2167        );
2168    }
2169
2170    #[test]
2171    fn test_parse_fortran_d_exponent() {
2172        assert!((parse_fortran_float("1.23D-5").unwrap() - 1.23e-5).abs() < 1e-20);
2173        assert!((parse_fortran_float("1.23d-5").unwrap() - 1.23e-5).abs() < 1e-20);
2174        assert!((parse_fortran_float("5.4D+3").unwrap() - 5.4e3).abs() < 1e-6);
2175        assert!((parse_fortran_float("1.0D0").unwrap() - 1.0).abs() < 1e-15);
2176    }
2177
2178    /// Verify that CAPTURE observation type is rejected with an error.
2179    #[test]
2180    fn test_capture_observation_type_rejected() {
2181        let content = "\
2182 FE TRANSMISSION FROM 1.13 TO 1.18 KEV
2183      FE56 55.9      1133.01   1170.517    99
2184
2185329.      80.263     0.0301   0.0       .021994
21866.0       0.2179
2187CAPTURE CROSS SECTION
2188  1      1    0  0.5    0.9999   .0
2189    1    1    0    0      .500      .000      .000
2190";
2191        let result = parse_sammy_inp(content);
2192        assert!(result.is_err(), "CAPTURE should be rejected");
2193        let msg = result.unwrap_err().message;
2194        assert!(
2195            msg.contains("CAPTURE"),
2196            "Error message should mention CAPTURE, got: {msg}"
2197        );
2198    }
2199
2200    /// Verify that DIRAC observation type is rejected with an error.
2201    #[test]
2202    fn test_dirac_observation_type_rejected() {
2203        let content = "\
2204 FE TRANSMISSION FROM 1.13 TO 1.18 KEV
2205      FE56 55.9      1133.01   1170.517    99
2206
2207329.      80.263     0.0301   0.0       .021994
22086.0       0.2179
2209DIRAC CROSS SECTION
2210  1      1    0  0.5    0.9999   .0
2211    1    1    0    0      .500      .000      .000
2212";
2213        let result = parse_sammy_inp(content);
2214        assert!(result.is_err(), "DIRAC should be rejected");
2215        let msg = result.unwrap_err().message;
2216        assert!(
2217            msg.contains("DIRAC"),
2218            "Error message should mention DIRAC, got: {msg}"
2219        );
2220    }
2221}