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