1use 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#[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#[derive(Debug, Clone)]
50pub struct SammyResonance {
51 pub energy_ev: f64,
53 pub gamma_gamma_ev: f64,
55 pub gamma_n_ev: f64,
57 pub gamma_f1_ev: f64,
59 pub gamma_f2_ev: f64,
61 pub spin_group: u32,
63}
64
65#[derive(Debug, Clone)]
70pub struct IsotopicMass {
71 pub awr: f64,
73 pub abundance: f64,
75 pub spin_groups: Vec<u32>,
77}
78
79#[derive(Debug, Clone)]
81pub struct SammyParFile {
82 pub resonances: Vec<SammyResonance>,
83 pub radius_overrides: std::collections::HashMap<u32, f64>,
87 pub r_external: std::collections::HashMap<u32, [f64; 7]>,
93 pub isotopic_masses: Vec<IsotopicMass>,
99 pub tzero: Option<Tzero>,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq)]
108pub struct Tzero {
109 pub t0_us: f64,
111 pub l0: f64,
113 pub flight_path_m: Option<f64>,
115}
116
117impl Tzero {
118 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#[derive(Debug, Clone)]
130pub struct SammySpinGroup {
131 pub index: u32,
133 pub j: f64,
136 pub l: u32,
138 pub abundance: f64,
140 pub target_spin: f64,
143 pub isotope_label: Option<String>,
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Default)]
154pub enum SammyObservationType {
155 #[default]
156 Transmission,
157 TotalCrossSection,
158 Fission,
159}
160
161#[derive(Debug, Clone)]
163pub struct SammyInpConfig {
164 pub title: String,
165 pub isotope_symbol: String,
167 pub awr: f64,
169 pub energy_min_ev: f64,
171 pub energy_max_ev: f64,
173 pub temperature_k: f64,
176 pub flight_path_m: f64,
178 pub delta_l_sammy: f64,
183 pub delta_e_sammy: f64,
188 pub delta_g_sammy: f64,
192 pub no_broadening: bool,
195 pub broadening_delta_l: Option<f64>,
199 pub broadening_delta_g: Option<f64>,
201 pub broadening_delta_e: Option<f64>,
203 pub scattering_radius_fm: f64,
205 pub thickness_atoms_barn: f64,
208 pub target_spin: f64,
213 pub spin_groups: Vec<SammySpinGroup>,
215 pub observation_type: SammyObservationType,
217}
218
219impl SammyInpConfig {
220 #[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 #[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 #[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#[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 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 Some((inp.flight_path_m, delta_t_us, delta_l_m, delta_e))
288}
289
290#[derive(Debug, Clone)]
294pub struct SammyPltRecord {
295 pub energy_kev: f64,
297 pub data: f64,
299 pub uncertainty: f64,
301 pub theory_initial: f64,
303 pub theory_final: f64,
305}
306
307pub 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 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 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
360fn parse_fortran_float(s: &str) -> Result<f64, std::num::ParseFloatError> {
368 if let Ok(v) = s.parse::<f64>() {
370 return Ok(v);
371 }
372 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 if s.len() > 1 {
383 for (i, c) in s[1..].char_indices() {
384 let pos = i + 1; 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
397pub 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 if trimmed.is_empty() || trimmed.starts_with("EXPLICIT") {
418 break;
419 }
420
421 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 ""
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 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 };
474
475 if spin_group_signed < 0 {
476 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 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; }
516
517 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 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; }
553 }
554
555 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 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; }
590
591 if rext_line.len() < 40 {
593 continue;
594 }
595
596 let sg: u32 = match rext_line[..2].trim().parse() {
598 Ok(v) if v > 0 => v,
599 _ => continue,
600 };
601 let param_str = &rext_line[10..];
608 let mut params = [0.0f64; 7];
609 let mut parsed_ok = true;
610
611 if param_str.len() >= 70 {
613 for (i, chunk) in (0..7).map(|i| (i, ¶m_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 if !parsed_ok {
628 let tokens: Vec<f64> = param_str
629 .split_whitespace()
630 .filter_map(|t| t.parse().ok())
631 .collect();
632 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 r_external.entry(sg).or_insert(params);
645 }
646 }
647
648 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 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 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
763pub 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 let title = lines[0].trim().to_string();
779
780 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 energy_min_ev
814 };
815
816 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 let mut idx = 2;
831 while idx < lines.len() {
833 let trimmed = lines[idx].trim();
834 if trimmed.is_empty() {
835 idx += 1;
836 break;
837 }
838 let upper = trimmed.to_uppercase();
840 if upper.starts_with("BROADENING IS NOT") || upper.contains("NO LOW-ENERGY BROADENING") {
842 no_broadening = true;
843 }
844 idx += 1;
848 }
849
850 let card5_present = if idx < lines.len() {
876 if no_broadening {
877 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 }
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 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 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 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 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 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 let (groups, parsed_target_spin) = parse_spin_groups(&lines[idx..])?;
985 spin_groups = groups;
986 target_spin = parsed_target_spin;
987 while idx < lines.len() && !lines[idx].trim().is_empty() {
989 idx += 1;
990 }
991 } else if trimmed.starts_with("BROADENING") {
992 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 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
1050fn 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 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; }
1092
1093 let line = lines[i];
1094
1095 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 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 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 if groups.is_empty() {
1163 target_spin = group_target_spin;
1164 }
1165
1166 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 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 i += 1 + n_channels as usize;
1216 }
1217
1218 Ok((groups, target_spin))
1219}
1220
1221const CHANNEL_OFFSET: f64 = 1e-6;
1226
1227fn 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
1259pub 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 let group_map: std::collections::HashMap<u32, &SammySpinGroup> =
1273 inp.spin_groups.iter().map(|sg| (sg.index, sg)).collect();
1274
1275 let mut l_group_map: std::collections::BTreeMap<u32, Vec<Resonance>> =
1278 std::collections::BTreeMap::new();
1279
1280 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 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 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 let l_groups: Vec<LGroup> = l_group_map
1341 .into_iter()
1342 .map(|(l, resonances)| {
1343 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 let target_spin = inp.target_spin;
1360
1361 let (z, mut a) = parse_isotope_symbol(&inp.isotope_symbol)?;
1364 if a == 0 {
1366 a = (inp.awr * NEUTRON_MASS_AMU).round() as u32;
1367 }
1368 let za = z * 1000 + a;
1369
1370 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 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
1418pub 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 let has_labels = inp.spin_groups.iter().any(|sg| sg.isotope_label.is_some());
1438
1439 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 let sg_map: std::collections::HashMap<u32, &SammySpinGroup> =
1484 inp.spin_groups.iter().map(|sg| (sg.index, sg)).collect();
1485
1486 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 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 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 let sg_indices: std::collections::HashSet<u32> = member_indices
1516 .iter()
1517 .map(|&i| inp.spin_groups[i].index)
1518 .collect();
1519
1520 let group_resonances: Vec<&SammyResonance> = par
1522 .resonances
1523 .iter()
1524 .filter(|r| sg_indices.contains(&r.spin_group))
1525 .collect();
1526
1527 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 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 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 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 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 let (z, mut a) = if has_labels {
1610 parse_isotope_symbol(key).unwrap_or((card2_z, card2_a))
1612 } else {
1613 (card2_z, card2_a)
1614 };
1615 if a == 0 {
1617 a = (isotope_awr * NEUTRON_MASS_AMU).round() as u32;
1618 }
1619 let za = z * 1000 + a;
1620
1621 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 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 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
1683pub(crate) fn parse_isotope_symbol(symbol: &str) -> Result<(u32, u32), SammyParseError> {
1690 let joined: String = symbol
1692 .chars()
1693 .filter(|c| !c.is_whitespace() && *c != '-')
1694 .collect();
1695 let s = joined.to_uppercase();
1696
1697 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 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 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 if let Some(z) = element_symbol_to_z(&s) {
1740 return Ok((z, 0));
1741 }
1742
1743 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
1753fn 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
1862fn 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#[cfg(test)]
1967mod tests {
1968 use super::*;
1969
1970 const FE56_TR007_INP: &str = include_str!(
1976 "../../../tests/data/samtry/tr007_fe56_transmission_doppler_resolution/t007a.inp"
1977 );
1978
1979 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 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); assert!((r0.gamma_n_ev - 186.6).abs() < 1e-6); assert_eq!(r0.spin_group, 1);
2055
2056 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); assert!((r2.gamma_n_ev - 0.062).abs() < 1e-6); 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 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 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 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 assert!((inp.target_spin - 0.0).abs() < 1e-6);
2106
2107 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); 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 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 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, l: 1, 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); 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 assert_eq!(rd.ranges[0].l_groups.len(), 2);
2206 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 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 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 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 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 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 #[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 #[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}